cd7d7f52b9
* 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>
88 lines
3.2 KiB
TypeScript
88 lines
3.2 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import type { Extension } from '@/lib/extensions/types'
|
|
import { registerEmailService } from '@/lib/email/service'
|
|
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { ResendEmailService } from './lib/resend-service'
|
|
import {
|
|
ResendDeliverySignatureError,
|
|
isDeliveryWebhookConfigured,
|
|
toDeliveryReport,
|
|
verifyDeliveryWebhook,
|
|
} from './lib/delivery-webhook'
|
|
|
|
// Register the Resend implementation immediately when this extension is loaded
|
|
registerEmailService(new ResendEmailService())
|
|
|
|
const log = createLogger('email-delivery-webhook')
|
|
|
|
export const emailExtension: Extension = {
|
|
id: 'email',
|
|
name: 'E-post (Resend)',
|
|
version: '1.0.0',
|
|
|
|
apiRoutes: [
|
|
// ── Resend delivery webhook (Svix-signed, no user auth) ──
|
|
// Reports whether a sent invoice email actually arrived. Resend pushes
|
|
// every event for the account to this endpoint, including mail that is not
|
|
// a tracked invoice delivery: unmatched reports are acknowledged and
|
|
// dropped so they are not retried forever.
|
|
{
|
|
method: 'POST',
|
|
path: '/delivery-status',
|
|
skipAuth: true,
|
|
handler: async (request: Request) => {
|
|
if (!isDeliveryWebhookConfigured()) {
|
|
log.error('RESEND_DELIVERY_WEBHOOK_SECRET is not configured', undefined)
|
|
return NextResponse.json({ error: 'Delivery webhook not configured' }, { status: 503 })
|
|
}
|
|
|
|
const rawBody = await request.text()
|
|
|
|
let event
|
|
try {
|
|
event = verifyDeliveryWebhook(rawBody, request.headers)
|
|
} catch (err) {
|
|
if (err instanceof ResendDeliverySignatureError) {
|
|
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
|
|
}
|
|
log.error('delivery webhook verification failed', err)
|
|
return NextResponse.json({ error: 'Verification failed' }, { status: 500 })
|
|
}
|
|
|
|
const report = toDeliveryReport(event)
|
|
if (!report) {
|
|
return NextResponse.json({ data: { applied: false, reason: 'ignored_event' } })
|
|
}
|
|
|
|
const { data, error } = await createServiceClientNoCookies().rpc(
|
|
'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,
|
|
},
|
|
)
|
|
|
|
// A failed apply must not be acknowledged: Svix retries non-2xx with
|
|
// backoff, which is exactly the recovery wanted for a transient
|
|
// database error.
|
|
if (error) {
|
|
log.error('failed to apply delivery status', error, { status: report.status })
|
|
return NextResponse.json({ error: 'Failed to record delivery status' }, { status: 500 })
|
|
}
|
|
|
|
if (!data) {
|
|
return NextResponse.json({ data: { applied: false, reason: 'no_matching_delivery' } })
|
|
}
|
|
|
|
log.info('delivery status applied', { deliveryId: data, status: report.status })
|
|
return NextResponse.json({ data: { applied: true } })
|
|
},
|
|
},
|
|
],
|
|
}
|