shared/inbox-tabs.ts
import type { Mail, Rule } from "./types.js";
// Inbox categories, in display order. Primary is the default for anything unsure.
export const inboxTabs = [
{ id: "primary", label: "Primary" },
{ id: "social", label: "Social" },
{ id: "promotions", label: "Promotions" },
{ id: "newsletters", label: "Newsletters" },
{ id: "transactions", label: "Transactions" },
{ id: "updates", label: "Updates" },
] as const;
export type InboxTab = (typeof inboxTabs)[number]["id"];
type Classified = Pick<Mail, "address" | "sender" | "subject" | "preview" | "reason" | "labelIds">;
// Codes, sign-in and security notices, support threads and replies always need the user.
const personal = /verification|verify|one[- ]time|\botp\b|passcode|security (alert|code|notice)|new sign[- ]?in|(sign[- ]?in|log[- ]?in) (attempt|alert|code|link|request)|(reset|change|forgot)\w* (your )?password|password (reset|change)|two[- ]factor|\b2fa\b|\bcode\b[^.]{0,20}\d{4,}|\d{4,}[^.]{0,20}\bcode\b|support (ticket|request|case)|\bticket\s*#|\bcase\s*#|^(re|fwd?):/i;
const gmailCategories: Record<string, InboxTab> = {
CATEGORY_PERSONAL: "primary",
CATEGORY_SOCIAL: "social",
CATEGORY_PROMOTIONS: "promotions",
CATEGORY_FORUMS: "newsletters",
CATEGORY_UPDATES: "updates",
};
// Reasons the AI sorter picks from (server/sorting.ts) that already name a category.
const reasonTabs: Record<string, InboxTab> = {
Receipt: "transactions",
Promotion: "promotions",
Newsletter: "newsletters",
Notification: "updates",
"Security alert": "primary",
"Needs a reply": "primary",
"Action needed": "primary",
Scheduling: "primary",
};
const transactions = /receipt|invoice|your order|order (#|no\.|number|confirm|shipped|delivered)|shipp(ed|ing) (confirm|update)|payment|\bpaid\b|refund|billing|subscription|your plan|charged|transaction|statement is ready/i;
const socialSenders = /@([a-z0-9-]+\.)*(linkedin\.com|facebookmail\.com|facebook\.com|instagram\.com|x\.com|twitter\.com|reddit(mail)?\.com|discord\.com|skool\.com|tiktok\.com|pinterest\.com|quora\.com|threads\.net|bsky\.app)$/i;
const social = /commented on|mentioned you|new follower|followed you|connection request|reacted to|liked your|replied to your (post|comment)|invited you to (join|connect)/i;
const newsletterSenders = /@([a-z0-9-]+\.)*(substack\.com|beehiiv\.com|mailchimp(app)?\.com|convertkit\.com|ghost\.io|buttondown\.email|mail\.beehiiv\.com)$/i;
const newsletters = /newsletter|digest|weekly|roundup|this week in|issue #?\d+|edition|peek at the week/i;
const promotions = /\bsale\b|% off|discount|\bdeals?\b|\boffer\b|coupon|promo|limited time|free trial|save \$?\d|black friday|cyber monday|shop now|exclusive/i;
const automatedSenders = /^(no-?reply|do-?not-?reply|notifications?|alerts?|updates?|mailer|info|news)@|@(notifications?|alerts?|mail|email|e)\./i;
const updates = /notification|alert|reminder|update|status|activity|job alert|new jobs|has (moved|changed)/i;
/**
* The inbox tab for a message. Order of trust: a Teach rule that marks the
* sender important, personal mail (codes, security, support, replies), Gmail's
* own category, the AI sorter's reason, then sender and subject patterns.
*/
export function inboxTab(mail: Classified, rules: Pick<Rule, "sender" | "category">[] = []): InboxTab {
const address = mail.address.toLowerCase();
if (rules.some((rule) => rule.category === "important" && rule.sender.toLowerCase() === address)) return "primary";
const text = `${mail.subject} ${mail.preview}`;
if (personal.test(mail.subject) || personal.test(text)) return "primary";
for (const id of mail.labelIds || []) if (gmailCategories[id]) return gmailCategories[id];
if (reasonTabs[mail.reason]) return reasonTabs[mail.reason];
if (transactions.test(text)) return "transactions";
if (socialSenders.test(address) || social.test(text)) return "social";
if (newsletterSenders.test(address) || newsletters.test(mail.subject)) return "newsletters";
if (promotions.test(text)) return "promotions";
if (automatedSenders.test(address) || updates.test(mail.subject)) return "updates";
if (/newsletter or promotion/i.test(mail.reason)) return "newsletters";
return "primary";
}