import { writeAudit } from './audit' import { decrypt } from './crypto' import type { DB } from './db' import { getAccessToken, TokenDeadError, type GmailDeps } from './gmail' import { getDocument } from './repos-documents' import { getAccount, markAccountDead } from './repos-email' import { getSetting, setSetting, upsertReminder } from './repos-reminders' /** * Bounce detection — "sent" ≠ "delivered" (spec §3). Polls the mailbox (Gmail * readonly) for mailer-daemon/postmaster DSNs since the last poll, matches failed * recipients against email_log, flips them to bounced and raises a dashboard * reminder. fetch is injected so tests run offline. Idempotent: a row only flips * once (bounced=0 guard) and the reminder keys on the email_log id. */ const GMAIL_LIST = 'https://gmail.googleapis.com/gmail/v1/users/me/messages' const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/g export interface BounceDeps { gmail: GmailDeps; now?: () => string } interface GmailMessageMeta { snippet?: string; payload?: { headers?: { name: string; value: string }[] } } function extractRecipients(msg: GmailMessageMeta): string[] { const out = new Set() for (const h of msg.payload?.headers ?? []) { if (h.name.toLowerCase() === 'x-failed-recipients') { for (const a of h.value.split(',')) { const t = a.trim(); if (t !== '') out.add(t.toLowerCase()) } } } for (const m of (msg.snippet ?? '').matchAll(EMAIL_RE)) out.add(m[0].toLowerCase()) return [...out] } /** Flip the most recent matching sent log to bounced + raise an email_bounced reminder. */ export function markBounce(db: DB, recipient: string, at: string): number { const row = db.prepare( `SELECT id, document_id FROM email_log WHERE lower(to_addr)=lower(?) AND status='sent' AND bounced=0 ORDER BY id DESC LIMIT 1`, ).get(recipient) as { id: string; document_id: string | null } | undefined if (row === undefined) return 0 db.prepare(`UPDATE email_log SET bounced=1 WHERE id=?`).run(row.id) const clientId = row.document_id !== null ? (getDocument(db, row.document_id)?.clientId ?? '') : '' upsertReminder(db, { ruleKind: 'email_bounced', subjectId: row.id, duePeriod: row.id, clientId, docId: row.document_id, now: at, }) writeAudit(db, 'system', 'update', 'email_log', row.id, { bounced: 0 }, { bounced: 1, recipient }) return 1 } export async function pollBounces(db: DB, deps: BounceDeps): Promise<{ scanned: number; bounced: number }> { const now = deps.now?.() ?? new Date().toISOString() const account = getAccount(db) if (account === null || account.status === 'dead') return { scanned: 0, bounced: 0 } let accessToken: string try { accessToken = await getAccessToken( decrypt(account.refreshTokenEnc, deps.gmail.keyHex), deps.gmail.clientId, deps.gmail.clientSecret, deps.gmail.f, ) } catch (err) { if (err instanceof TokenDeadError) markAccountDead(db) return { scanned: 0, bounced: 0 } } const since = getSetting(db, 'reminders.bounce_last_poll') ?? new Date(Date.parse(now) - 2 * 86_400_000).toISOString() const q = `from:mailer-daemon OR from:postmaster after:${Math.floor(Date.parse(since) / 1000)}` const headers = { authorization: `Bearer ${accessToken}` } let scanned = 0 let bounced = 0 try { const list = await deps.gmail.f(`${GMAIL_LIST}?q=${encodeURIComponent(q)}`, { headers }) const listJson = await list.json().catch(() => ({})) as { messages?: { id: string }[] } for (const { id } of listJson.messages ?? []) { scanned += 1 const msg = await deps.gmail.f( `${GMAIL_LIST}/${id}?format=metadata&metadataHeaders=Subject&metadataHeaders=X-Failed-Recipients`, { headers }, ) const meta = await msg.json().catch(() => ({})) as GmailMessageMeta for (const recipient of extractRecipients(meta)) bounced += markBounce(db, recipient, now) } } catch { // Network hiccup: leave bounce_last_poll unchanged so the next pass re-scans this window. return { scanned, bounced } } setSetting(db, 'system', 'reminders.bounce_last_poll', now) return { scanned, bounced } }