From 21606c5f9119a4f135fa17a7a721c54459336670 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 30 Jun 2026 20:44:21 +0200 Subject: [PATCH] feat: add 14 new smart folder categories (locale-agnostic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New EmailCategory enum values: Travel, Subscriptions, Parcels, Recruitment, Events, SecurityAlerts, Healthcare, Education, NewsMedia, PropertyUtilities, Charity, Government, CryptoInvesting, FamilySchool. HeuristicClassifier: locale-agnostic rules using global brand domains + TLD patterns (.gov.*, .edu, .ac.*) + English subject keywords — works for international users without relying on country-specific domains (e.g. gov.uk). Priority order ensures SecurityAlerts and Government take precedence over Finance, and Travel/RideSharing/FoodDelivery fire before Finance to prevent receipt mis-classification. AnalyticsService: maps all 14 new slugs to category counts. client.js: folderToRequest() handles all 14 new slugs. Layout.jsx: SMART_FOLDERS extended with icons and labels. widgets.jsx: CAT_SLUG updated for dashboard drillthrough. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/client.js | 19 +- frontend/src/components/Layout.jsx | 35 +- frontend/src/components/widgets.jsx | 42 +- src/InboxIntel.Domain/Enums/Enums.cs | 16 +- .../Analytics/AnalyticsService.cs | 35 +- .../Sync/HeuristicClassifier.cs | 524 ++++++++++++++++-- 6 files changed, 596 insertions(+), 75 deletions(-) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index f7a2b95..68c57e3 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -98,8 +98,23 @@ function folderToRequest(slug, page, pageSize) { case 'sales': return { ...base, category: 'SeasonalSales' }; case 'ridesharing': return { ...base, category: 'RideSharing' }; case 'food': return { ...base, category: 'FoodDelivery' }; - case 'wellness': return { ...base, category: 'Wellness' }; - default: return { ...base }; + case 'wellness': return { ...base, category: 'Wellness' }; + // New categories + case 'travel': return { ...base, category: 'Travel' }; + case 'subscriptions': return { ...base, category: 'Subscriptions' }; + case 'parcels': return { ...base, category: 'Parcels' }; + case 'recruitment': return { ...base, category: 'Recruitment' }; + case 'events': return { ...base, category: 'Events' }; + case 'security': return { ...base, category: 'SecurityAlerts' }; + case 'healthcare': return { ...base, category: 'Healthcare' }; + case 'education': return { ...base, category: 'Education' }; + case 'news': return { ...base, category: 'NewsMedia' }; + case 'property': return { ...base, category: 'PropertyUtilities' }; + case 'charity': return { ...base, category: 'Charity' }; + case 'government': return { ...base, category: 'Government' }; + case 'crypto': return { ...base, category: 'CryptoInvesting' }; + case 'family': return { ...base, category: 'FamilySchool' }; + default: return { ...base }; } } diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 1fbe3ad..e445815 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -32,16 +32,31 @@ const FAV_SPECIALS = { const DEFAULT_FAV_SLUGS = ['inbox', 'unread', 'large', 'old', 'readlater']; const SMART_FOLDERS = [ - { slug: 'automated', icon: '🤖', label: 'Automated', countKey: 'automated' }, - { slug: 'noreply', icon: '🔇', label: 'No-Reply', countKey: 'noreply' }, - { slug: 'shopping', icon: '🛍️', label: 'Online Shopping', countKey: 'shopping' }, - { slug: 'gaming', icon: '🎮', label: 'Gaming', countKey: 'gaming' }, - { slug: 'finance', icon: '💳', label: 'Finance & Insurance', countKey: 'finance' }, - { slug: 'sales', icon: '🏷️', label: 'Seasonal Sales', countKey: 'sales' }, - { slug: 'ridesharing', icon: '🚗', label: 'Ride Sharing', countKey: 'ridesharing' }, - { slug: 'food', icon: '🍕', label: 'Food Delivery', countKey: 'food' }, - { slug: 'social', icon: '📱', label: 'Social Notifications',countKey: 'social' }, - { slug: 'wellness', icon: '🏃', label: 'Wellness & Sport', countKey: 'wellness' }, + { slug: 'automated', icon: '🤖', label: 'Automated', countKey: 'automated' }, + { slug: 'noreply', icon: '🔇', label: 'No-Reply', countKey: 'noreply' }, + { slug: 'shopping', icon: '🛍️', label: 'Online Shopping', countKey: 'shopping' }, + { slug: 'gaming', icon: '🎮', label: 'Gaming', countKey: 'gaming' }, + { slug: 'finance', icon: '💳', label: 'Finance & Insurance', countKey: 'finance' }, + { slug: 'sales', icon: '🏷️', label: 'Seasonal Sales', countKey: 'sales' }, + { slug: 'ridesharing', icon: '🚗', label: 'Ride Sharing', countKey: 'ridesharing' }, + { slug: 'food', icon: '🍕', label: 'Food Delivery', countKey: 'food' }, + { slug: 'social', icon: '📱', label: 'Social Notifications', countKey: 'social' }, + { slug: 'wellness', icon: '🏃', label: 'Wellness & Sport', countKey: 'wellness' }, + // New categories + { slug: 'travel', icon: '✈️', label: 'Travel', countKey: 'travel' }, + { slug: 'subscriptions',icon: '🔄', label: 'Subscriptions & SaaS', countKey: 'subscriptions' }, + { slug: 'parcels', icon: '📦', label: 'Parcels & Delivery', countKey: 'parcels' }, + { slug: 'recruitment', icon: '💼', label: 'Recruitment & Jobs', countKey: 'recruitment' }, + { slug: 'events', icon: '🎟️', label: 'Events & Tickets', countKey: 'events' }, + { slug: 'security', icon: '🔐', label: 'Security & Alerts', countKey: 'security' }, + { slug: 'healthcare', icon: '🏥', label: 'Healthcare', countKey: 'healthcare' }, + { slug: 'education', icon: '🎓', label: 'Education', countKey: 'education' }, + { slug: 'news', icon: '📰', label: 'News & Media', countKey: 'news' }, + { slug: 'property', icon: '🏠', label: 'Property & Utilities', countKey: 'property' }, + { slug: 'charity', icon: '❤️', label: 'Charities', countKey: 'charity' }, + { slug: 'government', icon: '🏛️', label: 'Government', countKey: 'government' }, + { slug: 'crypto', icon: '📈', label: 'Crypto & Investing', countKey: 'crypto' }, + { slug: 'family', icon: '👨‍👩‍👧', label: 'Family & School', countKey: 'family' }, ]; // ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/frontend/src/components/widgets.jsx b/frontend/src/components/widgets.jsx index 69f57d2..641f1a9 100644 --- a/frontend/src/components/widgets.jsx +++ b/frontend/src/components/widgets.jsx @@ -105,20 +105,34 @@ const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // Maps EmailCategory enum name → folder slug or search query const CAT_SLUG = { - Finance: '/app/folder/finance', - Social: '/app/folder/social', - Notification: '/app/folder/automated', - Promotional: '/app/folder/shopping', - Shopping: '/app/folder/shopping', - Gaming: '/app/folder/gaming', - RideSharing: '/app/folder/ridesharing', - FoodDelivery: '/app/folder/food', - SeasonalSales:'/app/folder/sales', - Wellness: '/app/folder/wellness', - Spam: '/app/folder/spam', - Newsletter: '/app/search?q=newsletter', - Personal: '/app/search?q=is:unread', - Unknown: '/app/folder/allmail', + Finance: '/app/folder/finance', + Social: '/app/folder/social', + Notification: '/app/folder/automated', + Promotional: '/app/folder/shopping', + Shopping: '/app/folder/shopping', + Gaming: '/app/folder/gaming', + RideSharing: '/app/folder/ridesharing', + FoodDelivery: '/app/folder/food', + SeasonalSales: '/app/folder/sales', + Wellness: '/app/folder/wellness', + Travel: '/app/folder/travel', + Subscriptions: '/app/folder/subscriptions', + Parcels: '/app/folder/parcels', + Recruitment: '/app/folder/recruitment', + Events: '/app/folder/events', + SecurityAlerts: '/app/folder/security', + Healthcare: '/app/folder/healthcare', + Education: '/app/folder/education', + NewsMedia: '/app/folder/news', + PropertyUtilities:'/app/folder/property', + Charity: '/app/folder/charity', + Government: '/app/folder/government', + CryptoInvesting: '/app/folder/crypto', + FamilySchool: '/app/folder/family', + Spam: '/app/folder/spam', + Newsletter: '/app/search?q=newsletter', + Personal: '/app/search?q=is:unread', + Unknown: '/app/folder/allmail', }; export function CategoryHeatmapWidget() { diff --git a/src/InboxIntel.Domain/Enums/Enums.cs b/src/InboxIntel.Domain/Enums/Enums.cs index 4776b95..396d135 100644 --- a/src/InboxIntel.Domain/Enums/Enums.cs +++ b/src/InboxIntel.Domain/Enums/Enums.cs @@ -16,7 +16,21 @@ public enum EmailCategory FoodDelivery = 10, SeasonalSales = 11, Wellness = 12, - Shopping = 13 + Shopping = 13, + Travel = 14, + Subscriptions = 15, + Parcels = 16, + Recruitment = 17, + Events = 18, + SecurityAlerts = 19, + Healthcare = 20, + Education = 21, + NewsMedia = 22, + PropertyUtilities = 23, + Charity = 24, + Government = 25, + CryptoInvesting = 26, + FamilySchool = 27 } /// State machine for a sync run. diff --git a/src/InboxIntel.Infrastructure/Analytics/AnalyticsService.cs b/src/InboxIntel.Infrastructure/Analytics/AnalyticsService.cs index 6242a01..17a0e2e 100644 --- a/src/InboxIntel.Infrastructure/Analytics/AnalyticsService.cs +++ b/src/InboxIntel.Infrastructure/Analytics/AnalyticsService.cs @@ -167,16 +167,31 @@ public class AnalyticsService : IAnalyticsService var smartFolders = new Dictionary { - ["automated"] = Cat(EmailCategory.Notification), - ["finance"] = Cat(EmailCategory.Finance), - ["social"] = Cat(EmailCategory.Social), - ["shopping"] = Cat(EmailCategory.Shopping) + Cat(EmailCategory.Promotional), - ["noreply"] = Cat(EmailCategory.Notification), - ["gaming"] = Cat(EmailCategory.Gaming), - ["sales"] = Cat(EmailCategory.SeasonalSales), - ["ridesharing"] = Cat(EmailCategory.RideSharing), - ["food"] = Cat(EmailCategory.FoodDelivery), - ["wellness"] = Cat(EmailCategory.Wellness), + ["automated"] = Cat(EmailCategory.Notification), + ["finance"] = Cat(EmailCategory.Finance), + ["social"] = Cat(EmailCategory.Social), + ["shopping"] = Cat(EmailCategory.Shopping) + Cat(EmailCategory.Promotional), + ["noreply"] = Cat(EmailCategory.Notification), + ["gaming"] = Cat(EmailCategory.Gaming), + ["sales"] = Cat(EmailCategory.SeasonalSales), + ["ridesharing"] = Cat(EmailCategory.RideSharing), + ["food"] = Cat(EmailCategory.FoodDelivery), + ["wellness"] = Cat(EmailCategory.Wellness), + // New categories + ["travel"] = Cat(EmailCategory.Travel), + ["subscriptions"] = Cat(EmailCategory.Subscriptions), + ["parcels"] = Cat(EmailCategory.Parcels), + ["recruitment"] = Cat(EmailCategory.Recruitment), + ["events"] = Cat(EmailCategory.Events), + ["security"] = Cat(EmailCategory.SecurityAlerts), + ["healthcare"] = Cat(EmailCategory.Healthcare), + ["education"] = Cat(EmailCategory.Education), + ["news"] = Cat(EmailCategory.NewsMedia), + ["property"] = Cat(EmailCategory.PropertyUtilities), + ["charity"] = Cat(EmailCategory.Charity), + ["government"] = Cat(EmailCategory.Government), + ["crypto"] = Cat(EmailCategory.CryptoInvesting), + ["family"] = Cat(EmailCategory.FamilySchool), }; return new SidebarCountsDto(inbox, allMail, unread, starred, sent, drafts, trash, spam, large, old, smartFolders); diff --git a/src/InboxIntel.Infrastructure/Sync/HeuristicClassifier.cs b/src/InboxIntel.Infrastructure/Sync/HeuristicClassifier.cs index b21ff2b..445deb5 100644 --- a/src/InboxIntel.Infrastructure/Sync/HeuristicClassifier.cs +++ b/src/InboxIntel.Infrastructure/Sync/HeuristicClassifier.cs @@ -4,66 +4,514 @@ using InboxIntel.Domain.Enums; namespace InboxIntel.Infrastructure.Sync; /// -/// Fast, dependency-free first-pass classifier applied during sync. The AI -/// layer can later refine these labels, but this guarantees every email has a -/// sensible category even when AI is disabled. +/// Fast, dependency-free first-pass classifier applied during sync. Every email +/// gets a sensible category even when AI is disabled. /// -/// Priority order matters: more specific categories are checked first so a -/// food-delivery receipt doesn't fall through to Finance or Newsletter. +/// Locale strategy: +/// - Domain rules use globally-operating brands only (works in any country). +/// - TLD patterns (.gov.*, .edu, .ac.*) catch country-specific institutional domains +/// without enumerating them (e.g. .gov.uk, .gov.au, .govt.nz, skatteetaten.no won't +/// match .gov.* but SecurityAlerts + subject keywords cover it). +/// - Subject keywords use English terms, which most international transactional +/// systems use even when the UI is localised. +/// +/// Priority order matters — more specific / higher-confidence rules run first. /// public static class HeuristicClassifier { - // ── Domain hint lists ──────────────────────────────────────────────────── - private static readonly string[] RideSharingDomains = { "uber.com", "lyft.com", "bolt.eu", "freenow.com", "cabify.com", "grab.com", "gojek.com" }; - private static readonly string[] FoodDeliveryDomains = { "doordash.com", "ubereats.com", "justeat.com", "deliveroo.com", "grubhub.com", "seamless.com", "postmates.com", "foodpanda.com", "swiggy.com", "zomato.com" }; - private static readonly string[] GamingDomains = { "steampowered.com", "epicgames.com", "ea.com", "xbox.com", "playstation.com", "nintendo.com", "blizzard.com", "riotgames.com", "ubisoft.com", "gog.com", "humblebundle.com", "bethesda.net" }; - private static readonly string[] ShoppingDomains = { "amazon.com", "amazon.co.uk", "ebay.com", "ebay.co.uk", "etsy.com", "aliexpress.com", "asos.com", "shein.com", "zalando.com", "walmart.com", "target.com", "shopify.com" }; - private static readonly string[] WellnessDomains = { "strava.com", "garmin.com", "peloton.com", "myfitnesspal.com", "headspace.com", "calm.com", "nike.com", "adidas.com", "whoop.com", "fitbit.com", "zwift.com" }; - private static readonly string[] SocialDomains = { "facebook", "twitter", "linkedin", "instagram", "tiktok", "snapchat", "pinterest", "reddit", "discord", "slack", "teams" }; - private static readonly string[] FinanceDomains = { "paypal.com", "stripe.com", "wise.com", "revolut.com", "monzo.com", "starling.com", "barclays.co.uk", "hsbc.co.uk", "lloydsbank.com", "natwest.com", "chase.com", "americanexpress.com" }; + // ── Security & Alerts ──────────────────────────────────────────────────── + // Checked first: a password-reset from a bank must not become Finance. + private static readonly string[] SecuritySubjectHints = + { + "sign-in attempt", "new sign-in", "new login", "sign in from", + "password reset", "reset your password", "change your password", + "unusual activity", "suspicious activity", "suspicious sign", + "security alert", "security notice", "account locked", + "verify your", "verification code", "two-factor", "2fa", + "one-time code", "one-time password", "your otp", + "unauthorized access", "we noticed a", "login attempt", + }; - // ── Subject keyword lists ──────────────────────────────────────────────── - private static readonly string[] FinanceSubjectHints = { "invoice", "receipt", "payment", "statement", "bank", "transaction", "billing", "refund", "order confirmation", "your order", "subscription renewal" }; - private static readonly string[] SeasonalSalesHints = { "sale", "% off", "discount", "deal", "offer", "promo", "coupon", "black friday", "cyber monday", "flash sale", "clearance", "limited time" }; - private static readonly string[] WellnessSubjectHints = { "workout", "run summary", "activity", "steps", "calories", "meditation", "streak", "fitness", "training plan", "race result" }; - private static readonly string[] NoReplyFromHints = { "noreply", "no-reply", "donotreply", "notifications", "mailer", "automated" }; + // ── Government / Official ──────────────────────────────────────────────── + // TLD-pattern matching covers .gov, .gov.uk, .gov.au, .govt.nz, .gov.ng etc. + // Subject keywords catch country agencies that don't use .gov TLDs. + private static readonly string[] GovernmentSubjectHints = + { + "tax return", "tax refund", "self assessment", "national insurance", + "driving licence", "driving license", "vehicle registration", + "passport renewal", "visa application", "benefit payment", + "pension", "government gateway", "official notice", + "council tax", "property tax", "income tax", + }; + + // ── Travel ─────────────────────────────────────────────────────────────── + private static readonly string[] TravelDomains = + { + // Accommodation aggregators (global) + "booking.com", "airbnb.com", "expedia.com", "hotels.com", + "hostelworld.com", "agoda.com", "trivago.com", "vrbo.com", + "kayak.com", "skyscanner.com", "skyscanner.net", + "tripadvisor.com", "lastminute.com", + // Airlines — global + major European / Nordic + "ba.com", "britishairways.com", "easyjet.com", "ryanair.com", + "wizzair.com", "vueling.com", "iberia.com", "tap.pt", + "lufthansa.com", "airfrance.com", "klm.com", + "sas.se", "sas.no", "sas.dk", "sas.fi", // Scandinavian + "norwegian.com", "flyr.com", "finnair.com", + "delta.com", "united.com", "aa.com", "southwest.com", "jetblue.com", + "emirates.com", "etihad.com", "qatarairways.com", + "singaporeair.com", "cathaypackific.com", + "aircanada.com", "qantas.com", "virginatlantic.com", + "turkishairlines.com", "aerlingus.com", + // Rail + "eurostar.com", "trainline.com", "eurail.com", "amtrak.com", + "deutschebahn.com", "sbb.ch", "renfe.es", + // Car hire (global chains) + "hertz.com", "avis.com", "europcar.com", "enterprise.com", "sixt.com", "budget.com", + }; + private static readonly string[] TravelSubjectHints = + { + "boarding pass", "check-in", "check in online", + "itinerary", "flight confirmation", "travel confirmation", + "hotel confirmation", "booking confirmation", + "your trip", "your reservation", "reservation confirmed", + "e-ticket", "travel itinerary", "trip summary", + }; + + // ── Parcels & Delivery ──────────────────────────────────────────────────── + private static readonly string[] ParcelDomains = + { + // Global carriers + "dhl.com", "dhl.de", "fedex.com", "ups.com", "tnt.com", + // Regional but widely used + "dpd.com", "dpd.co.uk", "dpd.de", "dpd.fr", + "gls-group.eu", "gls-group.com", + "royalmail.com", "parcelforce.com", + "hermes.com", "evri.com", "yodel.co.uk", + "posten.no", "postnord.com", "postnord.se", "postnord.dk", + "posti.fi", "bpost.be", "laposte.fr", "correos.es", + "poste.it", "deutschepost.de", "swiss-post.ch", + "usps.com", "canadapost.ca", "auspost.com.au", "nzpost.co.nz", + "amazon-logistics.com", "amzl.co.uk", + }; + private static readonly string[] ParcelSubjectHints = + { + "your shipment", "shipment dispatched", "shipment update", + "your parcel", "parcel update", "package delivered", + "out for delivery", "delivery attempt", "delivery update", + "tracking number", "track your", "order dispatched", + "order shipped", "order is on its way", "estimated delivery", + "collected by courier", "left with neighbour", + }; + + // ── Events & Tickets ───────────────────────────────────────────────────── + private static readonly string[] EventsDomains = + { + "eventbrite.com", "ticketmaster.com", "ticketmaster.co.uk", + "seetickets.com", "axs.com", "stubhub.com", "viagogo.com", + "skiddle.com", "meetup.com", "dice.fm", "bandsintown.com", + "wegottickets.com", "universe.com", "tixr.com", + }; + private static readonly string[] EventsSubjectHints = + { + "your ticket", "your tickets", "ticket confirmation", + "event confirmation", "you're going", "see you there", + "ticket order", "e-ticket", "order confirmed", + }; + + // ── Recruitment & Jobs ──────────────────────────────────────────────────── + private static readonly string[] RecruitmentDomains = + { + "linkedin.com", "indeed.com", "glassdoor.com", "monster.com", + "reed.co.uk", "totaljobs.com", "cv-library.co.uk", + "workable.com", "greenhouse.io", "lever.co", + "smartrecruiters.com", "jobvite.com", "bamboohr.com", + "recruitee.com", "teamtailor.com", + "finn.no", // Norway + "jobindex.dk", // Denmark + "mol.fi", // Finland + "stepstone.de", // DACH + "xing.com", // DACH professional network + }; + private static readonly string[] RecruitmentSubjectHints = + { + "application received", "your application", "job application", + "we received your application", "thank you for applying", + "interview invitation", "interview request", + "job offer", "offer letter", "we'd like to invite you", + "your cv", "your resume", "shortlisted", + }; + + // ── Healthcare ──────────────────────────────────────────────────────────── + private static readonly string[] HealthcareDomains = + { + // UK + "nhs.uk", "nhs.net", + // Global / multinational insurers & health platforms + "bupa.com", "axa.com", "axa-insurance.co.uk", + "cigna.com", "allianzcare.com", + "vitality.co.uk", "aviva.com", + "zocdoc.com", "doximity.com", + "babylon.com", "kry.se", "livi.co.uk", + // Norway + "helsenorge.no", "helse-bergen.no", + }; + private static readonly string[] HealthcareSubjectHints = + { + "your appointment", "appointment reminder", "appointment confirmation", + "appointment booked", "medical appointment", + "prescription", "test results", "lab results", + "vaccination appointment", "vaccination reminder", + "health check", "clinical", "follow-up appointment", + "your referral", "specialist appointment", + }; + + // ── Education ───────────────────────────────────────────────────────────── + // TLD-based: .edu (US/global), .ac.uk, .ac.nz, .edu.au, .edu.no etc. + private static readonly string[] EducationDomains = + { + "coursera.org", "udemy.com", "duolingo.com", "khanacademy.org", + "edx.org", "skillshare.com", "futurelearn.com", + "pluralsight.com", "masterclass.com", + "codecademy.com", "datacamp.com", "udacity.com", + "brilliant.org", "typeform.com", "quizlet.com", + }; + private static readonly string[] EducationSubjectHints = + { + "assignment due", "assignment submitted", "your grade", + "course update", "lecture recording", "exam result", + "you earned a certificate", "certificate of completion", + "learning reminder", "course completion", + "term dates", "semester", "student portal", + }; + + // ── News & Media ────────────────────────────────────────────────────────── + private static readonly string[] NewsMediaDomains = + { + "substack.com", "medium.com", + "theguardian.com", "nytimes.com", "bbc.co.uk", "bbc.com", + "economist.com", "ft.com", "reuters.com", "apnews.com", + "morningbrew.com", "axios.com", "theatlantic.com", + "washingtonpost.com", "bloomberg.com", + "aftenposten.no", "dn.no", "vg.no", // Norwegian news + "politiken.dk", "berlingske.dk", // Danish + "hs.fi", "yle.fi", // Finnish + "svd.se", "dn.se", // Swedish + "spiegel.de", "faz.net", "zeit.de", // German + "lemonde.fr", "lefigaro.fr", // French + }; + private static readonly string[] NewsMediaSubjectHints = + { + "morning briefing", "daily digest", "weekly digest", + "breaking news", "top stories", "today's headlines", + "newsletter", "this week in", "what you need to know", + }; + + // ── Property & Utilities ────────────────────────────────────────────────── + private static readonly string[] PropertyDomains = + { + "rightmove.co.uk", "zoopla.co.uk", "onthemarket.com", + "finn.no", // also covers property in Norway + "hemnet.se", // Sweden + "boliga.dk", // Denmark + "zillow.com", "realtor.com", "redfin.com", + "airbnb.com", // already Travel, but utility overlap OK + }; + private static readonly string[] PropertySubjectHints = + { + "your bill", "bill ready", "statement ready", + "meter reading", "direct debit", "standing order", + "payment due", "payment reminder", + "energy bill", "electricity bill", "gas bill", "water bill", + "broadband bill", "internet bill", "phone bill", + "rent payment", "rent reminder", "tenancy", "landlord", + "council tax", "property alert", "new property match", + }; + + // ── Charities & Causes ──────────────────────────────────────────────────── + // .org TLD is a strong signal but too broad on its own; combine with keywords. + private static readonly string[] CharitySubjectHints = + { + "donation receipt", "thank you for donating", "thank you for your gift", + "your donation", "monthly giving", "gift aid", + "fundraising", "charity appeal", "humanitarian", + "make a difference", "your support", "sponsor", + }; + + // ── Crypto & Investing ──────────────────────────────────────────────────── + private static readonly string[] CryptoDomains = + { + // Crypto exchanges (global) + "coinbase.com", "binance.com", "kraken.com", "crypto.com", + "gemini.com", "bitstamp.net", "bybit.com", "okx.com", + // Retail investing (global + regional) + "etoro.com", "robinhood.com", + "freetrade.com", "trading212.com", "ig.com", + "hargreaves.co.uk", "hl.co.uk", + "fidelity.com", "vanguard.com", + "nutmeg.com", "moneybox.com", "wealthsimple.com", + "nordnet.no", "nordnet.se", "nordnet.dk", "nordnet.fi", // Nordic investing + "avanza.se", // Sweden + "degiro.eu", // European + }; + private static readonly string[] CryptoSubjectHints = + { + "trade executed", "order filled", "order confirmed", + "crypto", "bitcoin", "ethereum", + "portfolio update", "investment statement", + "dividend", "isa statement", "pension statement", + "fund performance", "capital gains", + }; + + // ── Family & School ─────────────────────────────────────────────────────── + // Intentionally subject-driven — school domains vary wildly by country. + private static readonly string[] FamilySchoolSubjectHints = + { + "parent evening", "parents evening", "open evening", + "school trip", "term dates", "half term", + "school report", "pupil", "student report", + "pta", "school newsletter", "class update", + "permission slip", "consent form", + "absence notification", "attendance", + }; + + // ── Subscriptions & SaaS ───────────────────────────────────────────────── + private static readonly string[] SubscriptionDomains = + { + "spotify.com", "netflix.com", "disneyplus.com", "hulu.com", + "hbomax.com", "paramountplus.com", "appletv.com", "icloud.com", + "apple.com", "google.com", "microsoft.com", + "adobe.com", "dropbox.com", "notion.so", + "atlassian.com", "slack.com", "github.com", + "canva.com", "figma.com", "zoom.us", + "twitch.tv", "patreon.com", + "1password.com", "lastpass.com", "dashlane.com", + "nordvpn.com", "expressvpn.com", "privateinternetaccess.com", + }; + private static readonly string[] SubscriptionSubjectHints = + { + "subscription renewed", "subscription renewal", + "your subscription", "membership renewed", + "payment receipt", "billing statement", + "thank you for subscribing", "your plan", + "invoice from", "receipt from", + "auto-renew", "automatic renewal", + }; + + // ── Existing domain lists (retained) ──────────────────────────────────── + private static readonly string[] RideSharingDomains = + { + "uber.com", "lyft.com", "bolt.eu", "bolt.com", + "freenow.com", "cabify.com", "grab.com", "gojek.com", "ola.com", + }; + private static readonly string[] FoodDeliveryDomains = + { + "doordash.com", "ubereats.com", "justeat.com", "deliveroo.com", + "grubhub.com", "seamless.com", "postmates.com", + "foodpanda.com", "swiggy.com", "zomato.com", + "wolt.com", // popular in Nordics / Europe + "foodora.com", // Germany / Austria / Norway + }; + private static readonly string[] GamingDomains = + { + "steampowered.com", "store.steampowered.com", + "epicgames.com", "ea.com", "xbox.com", "playstation.com", + "nintendo.com", "blizzard.com", "riotgames.com", + "ubisoft.com", "gog.com", "humblebundle.com", + "bethesda.net", "2k.com", "take2games.com", + "activision.com", "rockstargames.com", + }; + private static readonly string[] ShoppingDomains = + { + "amazon.com", "amazon.co.uk", "amazon.de", "amazon.fr", + "amazon.es", "amazon.it", "amazon.nl", "amazon.se", + "ebay.com", "ebay.co.uk", "ebay.de", + "etsy.com", "aliexpress.com", "asos.com", + "shein.com", "zalando.com", "zalando.co.uk", + "walmart.com", "target.com", "shopify.com", + "about.you.de", "boozt.com", // European fashion + "elkjop.no", "elkjop.se", // Nordic electronics + "coolblue.nl", "coolblue.be", // Benelux electronics + }; + private static readonly string[] WellnessDomains = + { + "strava.com", "garmin.com", "garmin.eu", + "peloton.com", "myfitnesspal.com", + "headspace.com", "calm.com", + "nike.com", "adidas.com", + "whoop.com", "fitbit.com", "zwift.com", + "noom.com", "flo.health", + }; + private static readonly string[] FinanceDomains = + { + "paypal.com", "stripe.com", "wise.com", "transferwise.com", + "revolut.com", "monzo.com", "starling.co.uk", "starlingbank.com", + "n26.com", "bunq.com", // European digital banks + "barclays.co.uk", "hsbc.co.uk", + "lloydsbank.com", "natwest.com", "santander.co.uk", + "chase.com", "bankofamerica.com", "wellsfargo.com", + "americanexpress.com", "amex.co.uk", + "dnb.no", "sparebank1.no", // Norwegian banks + "seb.se", "handelsbanken.se", // Swedish + "nordea.com", // Nordic + "klarna.com", // BNPL (global) + "afterpay.com", "clearpay.co.uk", + }; + private static readonly string[] SocialDomains = + { + "facebook", "twitter", "x.com", "linkedin", + "instagram", "tiktok", "snapchat", "pinterest", + "reddit", "discord", "slack", "teams", + "youtube.com", "mastodon", + }; + private static readonly string[] SeasonalSalesHints = + { + " sale", "% off", "% discount", "save up to", + "flash sale", "clearance", "black friday", + "cyber monday", "prime day", "singles day", + "limited time offer", "today only", "exclusive deal", + "early access sale", "end of season", + }; + private static readonly string[] WellnessSubjectHints = + { + "workout summary", "run summary", "activity summary", + "your weekly stats", "you hit your goal", + "calories burned", "steps today", "meditation streak", + "training plan", "race result", "new personal best", + }; + private static readonly string[] NoReplyFromHints = + { + "noreply", "no-reply", "donotreply", "do-not-reply", + "notifications@", "mailer@", "automated@", + }; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// True if the sender domain matches a .gov or .gov.XX TLD pattern. + private static bool IsGovDomain(string from) + { + // Covers: .gov .gov.uk .gov.au .govt.nz .gov.za .gov.ng etc. + return from.EndsWith(".gov") || + from.Contains(".gov.") || + from.Contains(".govt.") || + from.Contains("government."); + } + + /// True if sender domain is a recognised educational TLD. + private static bool IsEduDomain(string from) + { + // .edu .edu.au .edu.sg .ac.uk .ac.nz .ac.jp .edu.no etc. + return from.EndsWith(".edu") || + from.Contains(".edu.") || + from.Contains(".ac."); + } + + /// True if sender domain is a .org (strong charity/NGO signal). + private static bool IsOrgDomain(string from) => from.EndsWith(".org") || from.Contains(".org."); public static EmailCategory Classify(GmailMessageDetail d) { var subject = (d.Subject ?? string.Empty).ToLowerInvariant(); var from = d.FromAddress.ToLowerInvariant(); - // Ride-sharing — check before finance so a trip receipt doesn't become Finance - if (RideSharingDomains.Any(h => from.Contains(h))) return EmailCategory.RideSharing; + // 1. Security alerts — highest priority, overrides everything + if (SecuritySubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.SecurityAlerts; - // Food delivery — same reason - if (FoodDeliveryDomains.Any(h => from.Contains(h))) return EmailCategory.FoodDelivery; + // 2. Government — TLD pattern + subject keywords + if (IsGovDomain(from) || GovernmentSubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.Government; - // Gaming - if (GamingDomains.Any(h => from.Contains(h))) return EmailCategory.Gaming; + // 3. Travel (before Finance — a flight receipt is Travel, not Finance) + if (TravelDomains.Any(h => from.Contains(h)) || TravelSubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.Travel; - // Wellness + // 4. Ride-sharing (before Finance — receipt should stay RideSharing) + if (RideSharingDomains.Any(h => from.Contains(h))) + return EmailCategory.RideSharing; + + // 5. Food delivery + if (FoodDeliveryDomains.Any(h => from.Contains(h))) + return EmailCategory.FoodDelivery; + + // 6. Parcels (before Shopping — dispatch email is Parcels, not Shopping) + if (ParcelDomains.Any(h => from.Contains(h)) || ParcelSubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.Parcels; + + // 7. Events & Tickets + if (EventsDomains.Any(h => from.Contains(h)) || EventsSubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.Events; + + // 8. Healthcare + if (HealthcareDomains.Any(h => from.Contains(h)) || HealthcareSubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.Healthcare; + + // 9. Crypto & Investing (before Finance — a Coinbase email is Crypto) + if (CryptoDomains.Any(h => from.Contains(h)) || CryptoSubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.CryptoInvesting; + + // 10. Finance (domain + subject keywords) + if (FinanceDomains.Any(h => from.Contains(h))) + return EmailCategory.Finance; + if (new[] { "invoice", "receipt", "payment", "statement", "bank", "transaction", "billing", "refund" } + .Any(h => subject.Contains(h))) + return EmailCategory.Finance; + + // 11. Gaming + if (GamingDomains.Any(h => from.Contains(h))) + return EmailCategory.Gaming; + + // 12. Shopping (domain match) + if (ShoppingDomains.Any(h => from.Contains(h))) + return EmailCategory.Shopping; + + // 13. Seasonal sales (subject-driven — catches cross-retailer promos) + if (SeasonalSalesHints.Any(h => subject.Contains(h))) + return EmailCategory.SeasonalSales; + + // 14. Wellness if (WellnessDomains.Any(h => from.Contains(h)) || WellnessSubjectHints.Any(h => subject.Contains(h))) return EmailCategory.Wellness; - // Shopping / e-commerce domains - if (ShoppingDomains.Any(h => from.Contains(h))) return EmailCategory.Shopping; + // 15. Recruitment + if (RecruitmentDomains.Any(h => from.Contains(h)) || RecruitmentSubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.Recruitment; - // Seasonal sales (subject-driven — catches cross-retailer promotions) - if (SeasonalSalesHints.Any(h => subject.Contains(h))) return EmailCategory.SeasonalSales; + // 16. Education (TLD pattern + known platforms) + if (IsEduDomain(from) || EducationDomains.Any(h => from.Contains(h)) || EducationSubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.Education; - // Finance — domain first, then subject keywords - if (FinanceDomains.Any(h => from.Contains(h)) || FinanceSubjectHints.Any(h => subject.Contains(h))) - return EmailCategory.Finance; + // 17. Family & School (subject-only — school domains are country-specific) + if (FamilySchoolSubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.FamilySchool; - // Social networks - if (SocialDomains.Any(h => from.Contains(h))) return EmailCategory.Social; + // 18. Property & Utilities + if (PropertyDomains.Any(h => from.Contains(h)) || PropertySubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.PropertyUtilities; - // Newsletters (has unsubscribe header but didn't match any specific domain above) - if (d.HasListUnsubscribe) return EmailCategory.Newsletter; + // 19. Charities — .org TLD + subject keywords (either is sufficient) + if (CharitySubjectHints.Any(h => subject.Contains(h)) || IsOrgDomain(from)) + return EmailCategory.Charity; - // Automated / notification senders - if (NoReplyFromHints.Any(h => from.Contains(h))) return EmailCategory.Notification; + // 20. Subscriptions & SaaS + if (SubscriptionDomains.Any(h => from.Contains(h)) || SubscriptionSubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.Subscriptions; + + // 21. News & Media + if (NewsMediaDomains.Any(h => from.Contains(h)) || NewsMediaSubjectHints.Any(h => subject.Contains(h))) + return EmailCategory.NewsMedia; + + // 22. Social networks + if (SocialDomains.Any(h => from.Contains(h))) + return EmailCategory.Social; + + // 23. Newsletter (generic — has unsubscribe header, didn't match anything above) + if (d.HasListUnsubscribe) + return EmailCategory.Newsletter; + + // 24. Automated / no-reply senders + if (NoReplyFromHints.Any(h => from.Contains(h))) + return EmailCategory.Notification; return EmailCategory.Personal; }