using InboxIntel.Application.Abstractions; using InboxIntel.Domain.Enums; namespace InboxIntel.Infrastructure.Sync; /// /// Fast, dependency-free first-pass classifier applied during sync. Every email /// gets a sensible category even when AI is disabled. /// /// 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 { // ── 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", }; // ── 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(); // 1. Security alerts — highest priority, overrides everything if (SecuritySubjectHints.Any(h => subject.Contains(h))) return EmailCategory.SecurityAlerts; // 2. Government — TLD pattern + subject keywords if (IsGovDomain(from) || GovernmentSubjectHints.Any(h => subject.Contains(h))) return EmailCategory.Government; // 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; // 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; // 15. Recruitment if (RecruitmentDomains.Any(h => from.Contains(h)) || RecruitmentSubjectHints.Any(h => subject.Contains(h))) return EmailCategory.Recruitment; // 16. Education (TLD pattern + known platforms) if (IsEduDomain(from) || EducationDomains.Any(h => from.Contains(h)) || EducationSubjectHints.Any(h => subject.Contains(h))) return EmailCategory.Education; // 17. Family & School (subject-only — school domains are country-specific) if (FamilySchoolSubjectHints.Any(h => subject.Contains(h))) return EmailCategory.FamilySchool; // 18. Property & Utilities if (PropertyDomains.Any(h => from.Contains(h)) || PropertySubjectHints.Any(h => subject.Contains(h))) return EmailCategory.PropertyUtilities; // 19. Charities — .org TLD + subject keywords (either is sufficient) if (CharitySubjectHints.Any(h => subject.Contains(h)) || IsOrgDomain(from)) return EmailCategory.Charity; // 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; } }