|
|
|
@@ -4,66 +4,514 @@ using InboxIntel.Domain.Enums;
|
|
|
|
|
namespace InboxIntel.Infrastructure.Sync;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
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 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>True if the sender domain matches a .gov or .gov.XX TLD pattern.</summary>
|
|
|
|
|
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.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>True if sender domain is a recognised educational TLD.</summary>
|
|
|
|
|
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.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>True if sender domain is a .org (strong charity/NGO signal).</summary>
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|