Skip to content

Commit 77f4abc

Browse files
Auto-categorize inbound emails (Gmail-style: social, promo, updates)
On ingest, non-spam messages are categorized using sender domain whitelists and subject/body keywords: - Social: linkedin, facebook, github, reddit, x/twitter, etc. - Updates: no-reply/noreply senders, receipts, security alerts, SSL/domain expiry, deployment/build notifications - Promo: newsletter/marketing senders, deal/sale/discount subjects, "unsubscribe" in body - Primary: no tag (stays in inbox) Since all tagged messages are hidden from the inbox, the inbox naturally becomes "Primary" and the sidebar tags become category tabs. Retroactively tagged 11 existing messages: - 7 updates (no-reply senders) - 4 promo (marketing senders) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 0c7cd80 commit 77f4abc

1 file changed

Lines changed: 90 additions & 0 deletions

File tree

sync.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,15 @@ func upsertInbound(p *Poche, mailboxID string, doc map[string]any) (created bool
239239
_ = ensureTagRow(p, "dmarc")
240240
_ = ensureTag(p, localID, "dmarc")
241241
}
242+
// Auto-categorize non-spam messages (Gmail-style: social, promo, updates).
243+
// Primary = no tag (stays in inbox). Categorized messages are hidden from
244+
// inbox via the "hide all tagged" rule and appear in their tag view instead.
245+
if localID != "" && !isSpam(from, subj) {
246+
if cat := categorize(from, subj, text); cat != "" {
247+
_ = ensureTagRow(p, cat)
248+
_ = ensureTag(p, localID, cat)
249+
}
250+
}
242251
return true, nil
243252
}
244253

@@ -278,6 +287,87 @@ func isDMARC(from, subject string) bool {
278287
return strings.Contains(f, "dmarc") || strings.HasPrefix(s, "report domain:")
279288
}
280289

290+
// categorize assigns a Gmail-style category tag based on sender whitelists
291+
// and subject/body keywords. Returns "" for Primary (no category).
292+
// Best-effort — false positives are expected and the user can untag.
293+
func categorize(from, subject, body string) string {
294+
f := strings.ToLower(from)
295+
s := strings.ToLower(subject)
296+
b := strings.ToLower(body)
297+
298+
// ─── Social: sender domain whitelists ───────────────────────────
299+
socialDomains := []string{
300+
"linkedin.com", "facebook.com", "instagram.com",
301+
"twitter.com", "x.com", "youtube.com", "youtu.be",
302+
"reddit.com", "github.com", "gitlab.com",
303+
"medium.com", "tiktok.com", "pinterest.com",
304+
"mastodon", "bsky.app", "bluesky",
305+
"discord.com", "telegram.org",
306+
}
307+
for _, d := range socialDomains {
308+
if strings.Contains(f, d) {
309+
return "social"
310+
}
311+
}
312+
313+
// ─── Updates: receipts, security, service notifications ─────────
314+
updateSenders := []string{
315+
"stripe.com", "resend.com", "letsencrypt.org",
316+
"cloudflare.com", "github.com", "gitlab.com",
317+
"amazonaws.com", "google.com", "microsoft.com",
318+
"no-reply", "noreply", "donotreply", "notification",
319+
}
320+
updateKeywords := []string{
321+
"receipt", "invoice", "order confirmation", "shipped",
322+
"delivery", "tracking", "your order", "payment received",
323+
"security alert", "verification code", "2fa", "two-factor",
324+
"password reset", "account alert", "suspicious activity",
325+
"certificate", "ssl expir", "domain expir",
326+
"backup complete", "deployment", "build failed",
327+
"cron", "scheduled", "uptime", "incident", "status page",
328+
}
329+
for _, d := range updateSenders {
330+
if strings.Contains(f, d) {
331+
return "updates"
332+
}
333+
}
334+
for _, kw := range updateKeywords {
335+
if strings.Contains(s, kw) {
336+
return "updates"
337+
}
338+
}
339+
340+
// ─── Promo: newsletters, marketing, deals ───────────────────────
341+
promoSenders := []string{
342+
"newsletter", "marketing", "promo", "noreply",
343+
"mail.", "email.", "news@", "digest@",
344+
}
345+
promoSubjectKeywords := []string{
346+
"newsletter", "unsubscribe", "deal", "sale", "discount",
347+
"offer", "promotion", "limited time", "save up to",
348+
"% off", "free shipping", "black friday", "cyber monday",
349+
"exclusive", "members only", "early access", "new arrival",
350+
"weekly digest", "monthly roundup", "top picks",
351+
}
352+
for _, d := range promoSenders {
353+
if strings.Contains(f, d) {
354+
return "promo"
355+
}
356+
}
357+
for _, kw := range promoSubjectKeywords {
358+
if strings.Contains(s, kw) {
359+
return "promo"
360+
}
361+
}
362+
// Body keywords are weaker — only check if no subject match yet.
363+
// "unsubscribe" in body is a strong promo signal.
364+
if strings.Contains(b, "unsubscribe") || strings.Contains(b, "manage your preferences") {
365+
return "promo"
366+
}
367+
368+
return "" // Primary
369+
}
370+
281371
func findByResendID(p *Poche, resendID string) (string, error) {
282372
data, err := p.List("messages", "resend_id="+resendID, 1, 0, "", false)
283373
if err != nil {

0 commit comments

Comments
 (0)