From 7e1dcc1ad84a3119813c10889fd10b843ba8d018 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 15:40:03 +0530 Subject: [PATCH] feat(hq): mailbox bounce detection with email_bounced reminders Co-Authored-By: Claude Fable 5 --- apps/hq/scripts/gmail-connect.ts | 8 ++- apps/hq/src/bounces.ts | 89 ++++++++++++++++++++++++++++++++ apps/hq/test/bounces.test.ts | 49 ++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 apps/hq/src/bounces.ts create mode 100644 apps/hq/test/bounces.test.ts diff --git a/apps/hq/scripts/gmail-connect.ts b/apps/hq/scripts/gmail-connect.ts index f82ca3f..f41be55 100644 --- a/apps/hq/scripts/gmail-connect.ts +++ b/apps/hq/scripts/gmail-connect.ts @@ -19,7 +19,13 @@ const CALLBACK_PORT = 5190 const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth' const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token' const PROFILE_ENDPOINT = 'https://gmail.googleapis.com/gmail/v1/users/me/profile' -const SCOPE = 'https://www.googleapis.com/auth/gmail.send' +// Two scopes: send (HQ-1) and readonly (HQ-2 bounce polling reads mailer-daemon DSNs). +// The company mailbox is not yet connected in production, so requesting both now +// costs no extra consent — the first connect grants them together. +const SCOPE = [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.readonly', +].join(' ') /** Consent URL — offline access + forced consent so Google returns a refresh token. */ export function authUrl(clientId: string, redirectUri: string): string { diff --git a/apps/hq/src/bounces.ts b/apps/hq/src/bounces.ts new file mode 100644 index 0000000..eb0a811 --- /dev/null +++ b/apps/hq/src/bounces.ts @@ -0,0 +1,89 @@ +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 } +} diff --git a/apps/hq/test/bounces.test.ts b/apps/hq/test/bounces.test.ts new file mode 100644 index 0000000..df75eb3 --- /dev/null +++ b/apps/hq/test/bounces.test.ts @@ -0,0 +1,49 @@ +// apps/hq/test/bounces.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { saveAccount, logEmail } from '../src/repos-email' +import { encrypt } from '../src/crypto' +import { listReminders } from '../src/repos-reminders' +import { pollBounces, type BounceDeps } from '../src/bounces' + +const KEY = '11'.repeat(32) + +function bounceFetch(recipient: string): typeof fetch { + return (async (url: string) => { + const u = String(url) + if (u.includes('/token')) return new Response(JSON.stringify({ access_token: 'at' }), { status: 200 }) + if (u.includes('/messages/b1')) { + return new Response(JSON.stringify({ + snippet: `Delivery to ${recipient} failed permanently`, + payload: { headers: [{ name: 'X-Failed-Recipients', value: recipient }] }, + }), { status: 200 }) + } + return new Response(JSON.stringify({ messages: [{ id: 'b1' }] }), { status: 200 }) // list + }) as typeof fetch +} + +describe('pollBounces', () => { + it('flips the sent email_log to bounced and raises an email_bounced reminder, idempotently', async () => { + const db = openDb(':memory:'); seedIfEmpty(db) + saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY)) + logEmail(db, { to: 'ravi@acme.in', subject: 'INV/26-27-0001', status: 'sent', gmailMessageId: 'g1' }) + const deps: BounceDeps = { + gmail: { f: bounceFetch('ravi@acme.in'), clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, + now: () => '2026-07-10T00:00:00Z', + } + const out = await pollBounces(db, deps) + expect(out).toEqual({ scanned: 1, bounced: 1 }) + expect(db.prepare(`SELECT bounced FROM email_log WHERE to_addr='ravi@acme.in'`).get()).toMatchObject({ bounced: 1 }) + expect(listReminders(db, { ruleKind: 'email_bounced' })).toHaveLength(1) + // Re-poll: the row is already bounced=0→1, so nothing new flips and no duplicate reminder. + const again = await pollBounces(db, deps) + expect(again.bounced).toBe(0) + expect(listReminders(db, { ruleKind: 'email_bounced' })).toHaveLength(1) + }) + it('no-ops cleanly when Gmail is not connected', async () => { + const db = openDb(':memory:'); seedIfEmpty(db) + const deps: BounceDeps = { gmail: { f: bounceFetch('x@y.z'), clientId: '', clientSecret: '', keyHex: KEY }, now: () => '2026-07-10T00:00:00Z' } + expect(await pollBounces(db, deps)).toEqual({ scanned: 0, bounced: 0 }) + }) +})