feat(hq): mailbox bounce detection with email_bounced reminders
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
1b14e49d86
commit
7e1dcc1ad8
@ -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<string>()
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue