feat: expanded smart folder category coverage (Gaming, RideSharing, FoodDelivery, SeasonalSales, Wellness, Shopping)

- Added 6 new EmailCategory enum values
- HeuristicClassifier rewritten with domain and subject-keyword rules:
  - RideSharing: Uber, Lyft, Bolt, Free Now, Grab etc.
  - FoodDelivery: DoorDash, Uber Eats, Deliveroo, Just Eat, Grubhub etc.
  - Gaming: Steam, Epic, EA, Xbox, PlayStation, Nintendo, Blizzard, Riot etc.
  - Shopping: Amazon, eBay, Etsy, ASOS, Zalando, Shein etc.
  - SeasonalSales: subject keywords (sale, % off, promo, black friday etc.)
  - Wellness: Strava, Garmin, Peloton, MyFitnessPal, Headspace, Nike etc. + subject hints
  - Priority ordering prevents ride-share/food receipts misclassifying as Finance
- SidebarCounts maps all new slugs to their EmailCategory
- frontend folderToRequest() and CAT_SLUG map all new categories
- Existing emails will pick up new categories on next sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 20:24:07 +02:00
parent 2c9b402b08
commit 95dc835651
5 changed files with 78 additions and 19 deletions
+6 -1
View File
@@ -92,8 +92,13 @@ function folderToRequest(slug, page, pageSize) {
case 'automated': return { ...base, category: 'Notification' }; case 'automated': return { ...base, category: 'Notification' };
case 'finance': return { ...base, category: 'Finance' }; case 'finance': return { ...base, category: 'Finance' };
case 'social': return { ...base, category: 'Social' }; case 'social': return { ...base, category: 'Social' };
case 'shopping': return { ...base, category: 'Promotional' }; case 'shopping': return { ...base, category: 'Shopping' };
case 'noreply': return { ...base, query: 'from:noreply' }; case 'noreply': return { ...base, query: 'from:noreply' };
case 'gaming': return { ...base, category: 'Gaming' };
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 }; default: return { ...base };
} }
} }
+6
View File
@@ -109,6 +109,12 @@ const CAT_SLUG = {
Social: '/app/folder/social', Social: '/app/folder/social',
Notification: '/app/folder/automated', Notification: '/app/folder/automated',
Promotional: '/app/folder/shopping', 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', Spam: '/app/folder/spam',
Newsletter: '/app/search?q=newsletter', Newsletter: '/app/search?q=newsletter',
Personal: '/app/search?q=is:unread', Personal: '/app/search?q=is:unread',
+7 -1
View File
@@ -10,7 +10,13 @@ public enum EmailCategory
Spam = 4, Spam = 4,
Promotional = 5, Promotional = 5,
Social = 6, Social = 6,
Notification = 7 Notification = 7,
Gaming = 8,
RideSharing = 9,
FoodDelivery = 10,
SeasonalSales = 11,
Wellness = 12,
Shopping = 13
} }
/// <summary>State machine for a sync run.</summary> /// <summary>State machine for a sync run.</summary>
@@ -167,16 +167,16 @@ public class AnalyticsService : IAnalyticsService
var smartFolders = new Dictionary<string, int> var smartFolders = new Dictionary<string, int>
{ {
["automated"] = Cat(EmailCategory.Notification), ["automated"] = Cat(EmailCategory.Notification),
["finance"] = Cat(EmailCategory.Finance), ["finance"] = Cat(EmailCategory.Finance),
["social"] = Cat(EmailCategory.Social), ["social"] = Cat(EmailCategory.Social),
["shopping"] = Cat(EmailCategory.Promotional), ["shopping"] = Cat(EmailCategory.Shopping) + Cat(EmailCategory.Promotional),
["noreply"] = 0, ["noreply"] = Cat(EmailCategory.Notification),
["gaming"] = 0, ["gaming"] = Cat(EmailCategory.Gaming),
["sales"] = 0, ["sales"] = Cat(EmailCategory.SeasonalSales),
["ridesharing"] = 0, ["ridesharing"] = Cat(EmailCategory.RideSharing),
["food"] = 0, ["food"] = Cat(EmailCategory.FoodDelivery),
["wellness"] = 0, ["wellness"] = Cat(EmailCategory.Wellness),
}; };
return new SidebarCountsDto(inbox, allMail, unread, starred, sent, drafts, trash, spam, large, old, smartFolders); return new SidebarCountsDto(inbox, allMail, unread, starred, sent, drafts, trash, spam, large, old, smartFolders);
@@ -7,22 +7,64 @@ namespace InboxIntel.Infrastructure.Sync;
/// Fast, dependency-free first-pass classifier applied during sync. The AI /// Fast, dependency-free first-pass classifier applied during sync. The AI
/// layer can later refine these labels, but this guarantees every email has a /// layer can later refine these labels, but this guarantees every email has a
/// sensible category even when AI is disabled. /// 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.
/// </summary> /// </summary>
public static class HeuristicClassifier public static class HeuristicClassifier
{ {
private static readonly string[] FinanceHints = { "invoice", "receipt", "payment", "statement", "bank", "transaction", "billing" }; // ── Domain hint lists ────────────────────────────────────────────────────
private static readonly string[] SocialHints = { "facebook", "twitter", "linkedin", "instagram", "tiktok" }; private static readonly string[] RideSharingDomains = { "uber.com", "lyft.com", "bolt.eu", "freenow.com", "cabify.com", "grab.com", "gojek.com" };
private static readonly string[] NoReplyHints = { "noreply", "no-reply", "donotreply", "newsletter", "mailer", "notifications" }; 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" };
// ── 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" };
public static EmailCategory Classify(GmailMessageDetail d) public static EmailCategory Classify(GmailMessageDetail d)
{ {
var subject = (d.Subject ?? string.Empty).ToLowerInvariant(); var subject = (d.Subject ?? string.Empty).ToLowerInvariant();
var from = d.FromAddress.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;
// Food delivery — same reason
if (FoodDeliveryDomains.Any(h => from.Contains(h))) return EmailCategory.FoodDelivery;
// Gaming
if (GamingDomains.Any(h => from.Contains(h))) return EmailCategory.Gaming;
// 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;
// Seasonal sales (subject-driven — catches cross-retailer promotions)
if (SeasonalSalesHints.Any(h => subject.Contains(h))) return EmailCategory.SeasonalSales;
// Finance — domain first, then subject keywords
if (FinanceDomains.Any(h => from.Contains(h)) || FinanceSubjectHints.Any(h => subject.Contains(h)))
return EmailCategory.Finance;
// Social networks
if (SocialDomains.Any(h => from.Contains(h))) return EmailCategory.Social;
// Newsletters (has unsubscribe header but didn't match any specific domain above)
if (d.HasListUnsubscribe) return EmailCategory.Newsletter; if (d.HasListUnsubscribe) return EmailCategory.Newsletter;
if (FinanceHints.Any(h => subject.Contains(h))) return EmailCategory.Finance;
if (SocialHints.Any(h => from.Contains(h))) return EmailCategory.Social; // Automated / notification senders
if (NoReplyHints.Any(h => from.Contains(h))) return EmailCategory.Notification; if (NoReplyFromHints.Any(h => from.Contains(h))) return EmailCategory.Notification;
return EmailCategory.Personal; return EmailCategory.Personal;
} }
} }