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:
Mattsson
2026-08-03 17:56:37 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent bb1eddcccf
commit cd7d7f52b9
16 changed files with 1115 additions and 24 deletions
@@ -82,6 +82,15 @@ describe('GET /api/invoices/[id]/deliveries', () => {
provider_status: 'delivered',
provider_status_at: '2026-07-22T10:30:04.000Z',
provider_status_detail: null,
provider_recipient_statuses: {
'to:1': { status: 'delivered', status_at: '2026-07-22T10:30:04.000Z' },
'cc:1': { status: 'delivered', status_at: '2026-07-22T10:30:04.000Z' },
'bcc:1': { status: 'bounced', status_at: '2026-07-22T10:30:04.000Z' },
'customer@example.com': {
status: 'bounced',
status_at: '2026-07-22T10:30:04.000Z',
},
},
error_code: null,
document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf',
@@ -111,6 +120,10 @@ describe('GET /api/invoices/[id]/deliveries', () => {
provider_status: 'delivered',
provider_status_at: '2026-07-22T10:30:04.000Z',
provider_status_detail: null,
provider_recipient_statuses: {
'to:1': { status: 'delivered', status_at: '2026-07-22T10:30:04.000Z' },
'cc:1': { status: 'delivered', status_at: '2026-07-22T10:30:04.000Z' },
},
error_code: null,
document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf',
@@ -125,6 +138,8 @@ describe('GET /api/invoices/[id]/deliveries', () => {
expect(body.data[0]).not.toHaveProperty('body_text')
expect(body.data[0]).not.toHaveProperty('body_html')
expect(body.data[0]).not.toHaveProperty('provider_message_id')
expect(JSON.stringify(body.data[0])).not.toContain('customer@example.com')
expect(JSON.stringify(body.data[0])).not.toContain('bcc:1')
expect(body.data[0]).not.toHaveProperty('attachment_content_type')
expect(body.data[0]).not.toHaveProperty('attachment_sha256')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
+10 -4
View File
@@ -2,9 +2,11 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { sanitizeDeliveryRecipientStatuses } from '@/lib/invoices/delivery-recipient-statuses'
import type {
InvoiceDeliveryChannel,
InvoiceDeliveryProviderStatus,
InvoiceDeliveryRecipientStatuses,
InvoiceDeliveryStatus,
} from '@/types'
@@ -18,6 +20,7 @@ interface InvoiceDeliverySummaryRow {
provider_status: InvoiceDeliveryProviderStatus | null
provider_status_at: string | null
provider_status_detail: string | null
provider_recipient_statuses: InvoiceDeliveryRecipientStatuses
error_code: string | null
document_attachment_id: string | null
attachment_filename: string | null
@@ -42,12 +45,12 @@ interface MaskedInvoiceDeliverySummaryRow
* addresses stay server-side. The attachment filename passes through: it is
* derived from data the invoice already exposes to every company member. The
* database allow-list and masking boundary is defined by
* list_invoice_delivery_summaries in migration 20260724160000; this route
* list_invoice_delivery_summaries in the invoice delivery migrations; this route
* masks returned addresses again as defense in depth.
*
* The provider delivery outcome is message-level, never per recipient: the
* provider reports one result for the whole send, and its reason text can
* quote the failing address, so that text is masked the same way.
* Recipient outcomes use only stable To/CC positions. Exact addresses and BCC
* references never cross the database boundary, and reason text that can
* quote a failing address is masked again here.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.deliveries.list',
@@ -93,6 +96,9 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
provider_status: delivery.provider_status,
provider_status_at: delivery.provider_status_at,
provider_status_detail: maskAddressesInText(delivery.provider_status_detail),
provider_recipient_statuses: sanitizeDeliveryRecipientStatuses(
delivery.provider_recipient_statuses,
),
error_code: delivery.error_code,
document_attachment_id: delivery.document_attachment_id,
attachment_filename: delivery.attachment_filename,