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;
}