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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
bb1eddcccf
commit
cd7d7f52b9
@@ -104,6 +104,7 @@ describe('toDeliveryReport', () => {
|
||||
status: 'bounced',
|
||||
occurredAt: '2026-07-24T08:00:00.000Z',
|
||||
detail: '550 5.1.1 Recipient address rejected Permanent/General',
|
||||
recipients: ['customer@example.com'],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,9 +142,35 @@ describe('toDeliveryReport', () => {
|
||||
status: 'bounced',
|
||||
occurredAt: '2026-07-24T08:00:00.000Z',
|
||||
detail: null,
|
||||
recipients: ['customer@example.com'],
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes and deduplicates impacted recipients defensively', () => {
|
||||
const event = {
|
||||
type: 'email.delivered',
|
||||
created_at: '2026-07-24T08:00:00.000Z',
|
||||
data: baseData({
|
||||
to: [' Customer@example.com ', 'customer@example.com', 42, '', 'copy@example.org'],
|
||||
}),
|
||||
} as unknown as WebhookEventPayload
|
||||
|
||||
expect(toDeliveryReport(event)?.recipients).toEqual([
|
||||
'Customer@example.com',
|
||||
'copy@example.org',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a valid outcome when the impacted-recipient list is malformed', () => {
|
||||
const event = {
|
||||
type: 'email.bounced',
|
||||
created_at: '2026-07-24T08:00:00.000Z',
|
||||
data: baseData({ to: 'customer@example.com' }),
|
||||
} as unknown as WebhookEventPayload
|
||||
|
||||
expect(toDeliveryReport(event)?.recipients).toEqual([])
|
||||
})
|
||||
|
||||
it('drops an event without a provider message id', () => {
|
||||
const event = {
|
||||
type: 'email.delivered',
|
||||
@@ -235,12 +262,13 @@ describe('POST /api/extensions/ext/email/delivery-status', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toEqual({ applied: true })
|
||||
expect(rpcMock).toHaveBeenCalledWith('apply_invoice_delivery_provider_status', {
|
||||
expect(rpcMock).toHaveBeenCalledWith('apply_invoice_delivery_provider_event', {
|
||||
p_provider: 'resend',
|
||||
p_provider_message_id: 'msg-1',
|
||||
p_status: 'bounced',
|
||||
p_occurred_at: '2026-07-24T08:00:00.000Z',
|
||||
p_detail: 'Mailbox unavailable Permanent/General',
|
||||
p_recipient_addresses: ['customer@example.com'],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -56,13 +56,14 @@ export const emailExtension: Extension = {
|
||||
}
|
||||
|
||||
const { data, error } = await createServiceClientNoCookies().rpc(
|
||||
'apply_invoice_delivery_provider_status',
|
||||
'apply_invoice_delivery_provider_event',
|
||||
{
|
||||
p_provider: 'resend',
|
||||
p_provider_message_id: report.providerMessageId,
|
||||
p_status: report.status,
|
||||
p_occurred_at: report.occurredAt,
|
||||
p_detail: report.detail,
|
||||
p_recipient_addresses: report.recipients,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
*
|
||||
* "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, per message: one report covers every
|
||||
* recipient on that message, and the reason text names the address that
|
||||
* failed. This module verifies the signed payload and maps it onto the
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ProviderDeliveryReport {
|
||||
status: InvoiceDeliveryProviderStatus
|
||||
occurredAt: string
|
||||
detail: string | null
|
||||
recipients: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +84,27 @@ 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"
|
||||
@@ -123,16 +145,18 @@ export function toDeliveryReport(event: WebhookEventPayload): ProviderDeliveryRe
|
||||
const status = STATUS_BY_EVENT[event.type]
|
||||
if (!status) return null
|
||||
|
||||
const data = event.data as { email_id?: string }
|
||||
if (!data.email_id) 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: data.email_id,
|
||||
providerMessageId,
|
||||
status,
|
||||
occurredAt,
|
||||
detail: reasonText(event),
|
||||
recipients: recipientAddresses(data.to),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ const BOUNCED_ROW = {
|
||||
provider_status: 'bounced',
|
||||
provider_status_at: '2026-07-20T10:00:00+00:00',
|
||||
provider_status_detail: 'smtp; 550 5.1.1 ***@example.com recipient rejected',
|
||||
provider_recipient_statuses: {
|
||||
'to:1': { status: 'bounced', status_at: '2026-07-20T10:00:00+00:00' },
|
||||
'cc:1': { status: 'delivered', status_at: '2026-07-20T09:59:00+00:00' },
|
||||
},
|
||||
error_code: null,
|
||||
document_attachment_id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
|
||||
attachment_filename: 'faktura-1042.pdf',
|
||||
@@ -114,6 +118,10 @@ describe('gnubok_get_invoice_deliveries: execute', () => {
|
||||
expect(delivery.provider_status).toBe('bounced')
|
||||
expect(delivery.provider_status_at).toBe('2026-07-20T10:00:00+00:00')
|
||||
expect(delivery.provider_status_detail).toContain('550 5.1.1')
|
||||
expect(delivery.provider_recipient_statuses).toEqual({
|
||||
'to:1': { status: 'bounced', status_at: '2026-07-20T10:00:00+00:00' },
|
||||
'cc:1': { status: 'delivered', status_at: '2026-07-20T09:59:00+00:00' },
|
||||
})
|
||||
expect(delivery.error_code).toBeNull()
|
||||
expect(delivery.to_addresses).toEqual(['***@example.com'])
|
||||
expect(delivery.cc_addresses).toEqual(['***@example.org'])
|
||||
@@ -160,6 +168,14 @@ describe('gnubok_get_invoice_deliveries: execute', () => {
|
||||
body_text: 'LEAKED-BODY-TEXT',
|
||||
body_html: '<p>LEAKED-BODY-HTML</p>',
|
||||
bcc_addresses: ['leaked.bcc@example.net'],
|
||||
provider_recipient_statuses: {
|
||||
...BOUNCED_ROW.provider_recipient_statuses,
|
||||
'bcc:1': { status: 'bounced', status_at: '2026-07-20T10:00:00+00:00' },
|
||||
'leaked.bcc@example.net': {
|
||||
status: 'bounced',
|
||||
status_at: '2026-07-20T10:00:00+00:00',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -178,6 +194,7 @@ describe('gnubok_get_invoice_deliveries: execute', () => {
|
||||
expect(serialized).not.toContain('LEAKED-BODY-HTML')
|
||||
expect(serialized).not.toContain('leaked.bcc')
|
||||
expect(serialized).not.toContain('bcc_addresses')
|
||||
expect(serialized).not.toContain('bcc:1')
|
||||
})
|
||||
|
||||
it('throws Invoice not found for an invoice outside the routed company', async () => {
|
||||
|
||||
@@ -138,6 +138,7 @@ import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliatio
|
||||
import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope'
|
||||
import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { findMatchingInvoices } from '@/lib/invoices/invoice-matching'
|
||||
import { sanitizeDeliveryRecipientStatuses } from '@/lib/invoices/delivery-recipient-statuses'
|
||||
import { listRotRutCandidates, createRotRutPayoutRequest } from '@/lib/invoices/rot-rut-service'
|
||||
import { importRotRutBeslutFile } from '@/lib/invoices/rot-rut-beslut-import'
|
||||
import { RotRutBeslutFileSchema } from '@/lib/api/schemas'
|
||||
@@ -5447,6 +5448,19 @@ export const tools: McpTool[] = [
|
||||
type: ['string', 'null'],
|
||||
description: 'Provider reason text for a failure, with address local parts masked.',
|
||||
},
|
||||
provider_recipient_statuses: {
|
||||
type: 'object',
|
||||
description: 'PII-free outcomes keyed by stable To/CC positions such as to:1 and cc:1. BCC is never included.',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
status_at: { type: 'string' },
|
||||
},
|
||||
required: ['status', 'status_at'],
|
||||
},
|
||||
},
|
||||
error_code: { type: ['string', 'null'] },
|
||||
to_addresses: {
|
||||
type: 'array',
|
||||
@@ -5513,6 +5527,7 @@ export const tools: McpTool[] = [
|
||||
provider_status: string | null
|
||||
provider_status_at: string | null
|
||||
provider_status_detail: string | null
|
||||
provider_recipient_statuses: Record<string, { status: string; status_at: string }> | null
|
||||
error_code: string | null
|
||||
attachment_filename: string | null
|
||||
sent_at: string | null
|
||||
@@ -5527,6 +5542,9 @@ export const tools: McpTool[] = [
|
||||
provider_status: row.provider_status ?? null,
|
||||
provider_status_at: row.provider_status_at ?? null,
|
||||
provider_status_detail: row.provider_status_detail ?? null,
|
||||
provider_recipient_statuses: sanitizeDeliveryRecipientStatuses(
|
||||
row.provider_recipient_statuses,
|
||||
),
|
||||
error_code: row.error_code ?? null,
|
||||
to_addresses: row.to_addresses ?? [],
|
||||
cc_addresses: row.cc_addresses ?? [],
|
||||
|
||||
Reference in New Issue
Block a user