You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/bounces.ts

90 lines
4.1 KiB
TypeScript

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 async function markBounce(db: DB, recipient: string, at: string): Promise<number> {
const row = await db.get<{ id: string; document_id: string | null }>(
`SELECT id, document_id FROM email_log
WHERE lower(to_addr)=lower(?) AND status='sent' AND bounced=0
ORDER BY id DESC LIMIT 1`, recipient,
)
if (row === undefined) return 0
await db.run(`UPDATE email_log SET bounced=1 WHERE id=?`, row.id)
const clientId = row.document_id !== null ? ((await getDocument(db, row.document_id))?.clientId ?? '') : ''
await upsertReminder(db, {
ruleKind: 'email_bounced', subjectId: row.id, duePeriod: row.id, clientId,
docId: row.document_id, now: at,
})
await 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 = await 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) await markAccountDead(db)
return { scanned: 0, bounced: 0 }
}
const since = await 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 += await markBounce(db, recipient, now)
}
} catch {
// Network hiccup: leave bounce_last_poll unchanged so the next pass re-scans this window.
return { scanned, bounced }
}
await setSetting(db, 'system', 'reminders.bounce_last_poll', now)
return { scanned, bounced }
}