Files
accounted/extensions/general/email/lib/delivery-webhook.ts
T
MattssonandClaude Fable 5 cd7d7f52b9 feat(invoices): per-recipient email delivery outcomes (#1384)
* feat(invoices): per-recipient email delivery outcomes

Resend delivery webhooks identify affected addresses in data.to, so one
message with CC recipients can carry independent To/CC outcomes instead
of masking the failing address into the aggregate reason text.

- new apply_invoice_delivery_provider_event RPC merges each reported
  recipient onto its immutable To/CC position with the same rank and
  timestamp ordering as the aggregate status (retry and out-of-order safe)
- recipient map is PII-free: keyed to:N / cc:N, BCC and unmatched
  recipients are never represented, and the map is cleared on PII redaction
- delivery summaries, API route and MCP tool expose the sanitized map;
  the route re-sanitizes as defense in depth
- UI shows a per-recipient status list under the aggregate outcome

The prod ops check in issue #1350 (webhook registered in Resend and
RESEND_DELIVERY_WEBHOOK_SECRET set in Vercel) cannot be verified from the
repo and remains a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(invoices): commit provider event before cross-context read

The BCC-leak test applied the event inside the rollback-scoped service
role helper and then asserted through a separate member context, so the
applied status was rolled back before the read. Use the committing
runAsServiceRole helper for the apply, matching how the summary read is
performed in its own context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:56:37 +02:00

170 lines
5.2 KiB
TypeScript

/**
* Resend outbound delivery webhook.
*
* "Accepted by Resend" and "the recipient's server took it" are two different
* facts, and only the first one is known when a send returns. Resend reports
* the second one asynchronously. Resend identifies the recipient(s) affected
* by each event in `data.to`, which lets one message carry independent To/CC
* outcomes. This module verifies the signed payload and maps it onto the
* provider status stored on the invoice delivery row.
*/
import { Resend } from 'resend'
import type { WebhookEventPayload } from 'resend'
import type { InvoiceDeliveryProviderStatus } from '@/types'
export class ResendDeliverySignatureError extends Error {
constructor(message: string) {
super(message)
this.name = 'ResendDeliverySignatureError'
}
}
export interface ProviderDeliveryReport {
providerMessageId: string
status: InvoiceDeliveryProviderStatus
occurredAt: string
detail: string | null
recipients: string[]
}
/**
* Events that say something about whether the message arrived. `email.sent`
* and `email.scheduled` only repeat what the send call already told us, and
* open/click tracking is not enabled: both are ignored on purpose.
*/
const STATUS_BY_EVENT: Record<string, InvoiceDeliveryProviderStatus> = {
'email.delivered': 'delivered',
'email.delivery_delayed': 'delayed',
'email.complained': 'complained',
'email.bounced': 'bounced',
'email.failed': 'failed',
'email.suppressed': 'suppressed',
}
export function isDeliveryWebhookConfigured(): boolean {
return !!process.env.RESEND_DELIVERY_WEBHOOK_SECRET
}
/**
* Verifies the Svix-signed payload against RESEND_DELIVERY_WEBHOOK_SECRET.
* This is a separate Resend endpoint from the inbound document mailbox, so it
* carries its own signing secret.
*/
export function verifyDeliveryWebhook(
rawBody: string,
requestHeaders: Headers,
): WebhookEventPayload {
const secret = process.env.RESEND_DELIVERY_WEBHOOK_SECRET
if (!secret) throw new Error('RESEND_DELIVERY_WEBHOOK_SECRET is required')
const apiKey = process.env.RESEND_API_KEY
if (!apiKey) throw new Error('RESEND_API_KEY is required')
const svixHeaders = {
id: requestHeaders.get('svix-id') ?? '',
timestamp: requestHeaders.get('svix-timestamp') ?? '',
signature: requestHeaders.get('svix-signature') ?? '',
}
try {
return new Resend(apiKey).webhooks.verify({
payload: rawBody,
headers: svixHeaders,
webhookSecret: secret,
})
} catch (err) {
throw new ResendDeliverySignatureError(
err instanceof Error ? err.message : 'Invalid signature',
)
}
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function recipientAddresses(value: unknown): string[] {
if (!Array.isArray(value)) return []
const seen = new Set<string>()
const recipients: string[] = []
for (const valueItem of value) {
const address = text(valueItem)
if (!address || address.length > 320) continue
const normalized = address.toLocaleLowerCase('en-US')
if (seen.has(normalized)) continue
seen.add(normalized)
recipients.push(address)
if (recipients.length === 100) break
}
return recipients
}
/**
* The reason is read defensively: the payload is external input, and a
* provider that ships a new event shape must degrade to "no reason given"
* rather than throw, which would turn every retry into another failed
* delivery report.
*/
function reasonText(event: WebhookEventPayload): string | null {
const data = event.data as {
bounce?: { message?: unknown; subType?: unknown; type?: unknown }
failed?: { reason?: unknown }
suppressed?: { message?: unknown; type?: unknown }
}
if (event.type === 'email.bounced') {
const classification = [text(data.bounce?.type), text(data.bounce?.subType)]
.filter(Boolean)
.join('/')
return [text(data.bounce?.message), classification || null].filter(Boolean).join(' ') || null
}
if (event.type === 'email.failed') {
return text(data.failed?.reason)
}
if (event.type === 'email.suppressed') {
return [text(data.suppressed?.message), text(data.suppressed?.type)]
.filter(Boolean)
.join(' ') || null
}
return null
}
/**
* Maps a verified event onto a delivery report, or null when the event says
* nothing about arrival. The provider clock wins over ingestion time: webhooks
* can be retried hours later, and the status timestamp must stay the moment
* the outcome actually happened.
*/
export function toDeliveryReport(event: WebhookEventPayload): ProviderDeliveryReport | null {
const status = STATUS_BY_EVENT[event.type]
if (!status) return null
const data = event.data as { email_id?: unknown; to?: unknown }
const providerMessageId = text(data.email_id)
if (!providerMessageId) return null
const occurredAt = parseTimestamp(event.created_at)
return {
providerMessageId,
status,
occurredAt,
detail: reasonText(event),
recipients: recipientAddresses(data.to),
}
}
function parseTimestamp(value: unknown): string {
if (typeof value === 'string') {
const parsed = new Date(value)
if (!Number.isNaN(parsed.getTime())) return parsed.toISOString()
}
return new Date().toISOString()
}