feat: add 14 new smart folder categories (locale-agnostic)

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 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 20:44:21 +02:00
parent 95dc835651
commit 21606c5f91
6 changed files with 596 additions and 75 deletions
+17 -2
View File
@@ -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 };
}
}
+25 -10
View File
@@ -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 ───────────────────────────────────────────────────────────────────
+28 -14
View File
@@ -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() {
+15 -1
View File
@@ -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
}
/// <summary>State machine for a sync run.</summary>
@@ -167,16 +167,31 @@ public class AnalyticsService : IAnalyticsService
var smartFolders = new Dictionary<string, int>
{
["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);
@@ -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;
}