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:
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user