diff --git a/apps/website/src/app/api/webhooks/resend/route.ts b/apps/website/src/app/api/webhooks/resend/route.ts index 435d07489..e25f6287c 100644 --- a/apps/website/src/app/api/webhooks/resend/route.ts +++ b/apps/website/src/app/api/webhooks/resend/route.ts @@ -99,18 +99,35 @@ export function createResendWebhookRoute( return response(503); } try { - const result = await dependencies.processVerifiedResendWebhook(database, { - providerEventId: id, - payload: verifiedPayload, - }); - if ( - !result.applied && - result.reason === 'retryable_unmatched_job' - ) { - return response(503); - } - return response(200); - } catch { + const result = await dependencies.processVerifiedResendWebhook( + database, + { + providerEventId: id, + payload: verifiedPayload, + } + ); + const status = + !result.applied && result.reason === 'retryable_unmatched_job' + ? 503 + : 200; + console.info( + JSON.stringify({ + route: 'webhooks/resend', + status, + applied: result.applied, + reason: result.applied ? undefined : result.reason, + }) + ); + return response(status); + } catch (error) { + console.info( + JSON.stringify({ + route: 'webhooks/resend', + status: 400, + reason: 'payload_rejected', + message: error instanceof Error ? error.message : 'unknown', + }) + ); return response(400); } finally { await database.close?.(); diff --git a/libs/growth/src/lib/webhooks.spec.ts b/libs/growth/src/lib/webhooks.spec.ts index c748c4db3..b7295dec0 100644 --- a/libs/growth/src/lib/webhooks.spec.ts +++ b/libs/growth/src/lib/webhooks.spec.ts @@ -335,16 +335,72 @@ describe('processVerifiedResendWebhook', () => { ).resolves.toMatchObject({ applied: true }); }); - it('still rejects unknown data keys so the closed schema stays enforced', async () => { - const harness = executorWith({}); + it('accepts the real Resend payload for a message with custom headers (headers array present)', async () => { + // Captured verbatim from the production webhook event log on + // 2026-09-03: recipient messages carry List-Unsubscribe, the job id, + // Bcc, and Reply-To as a `headers` array. Rejecting it left every + // recipient delivery stuck at "submitted". + const harness = webhookHarness(); await expect( - processVerifiedResendWebhook(harness.executor, { - providerEventId: 'msg_unknown_key', - payload: event('email.delivered', { headers: [] }), + processVerifiedResendWebhook( + harness.executor, + { + providerEventId: 'msg_headers_shape', + payload: event('email.delivered', { + message_id: '<111-222-333@email.example.com>', + headers: [ + { + name: 'List-Unsubscribe', + value: '', + }, + { + name: 'List-Unsubscribe-Post', + value: 'List-Unsubscribe=One-Click', + }, + { + name: 'X-Threadplane-Job-ID', + value: '68add347-ef99-414c-b96f-89475cf4ee26', + }, + { + name: 'Bcc', + value: 'Brian at Threadplane ', + }, + { + name: 'Reply-To', + value: 'Brian at Threadplane ', + }, + ], + }), + }, + harness.dependencies + ) + ).resolves.toMatchObject({ applied: true }); + }); + + it('ignores unknown data keys instead of failing the whole event, within a bounded key count', async () => { + const harness = webhookHarness(); + + await expect( + processVerifiedResendWebhook( + harness.executor, + { + providerEventId: 'msg_unknown_key', + payload: event('email.delivered', { some_future_field: 'x' }), + }, + harness.dependencies + ) + ).resolves.toMatchObject({ applied: true }); + + const tooMany = Object.fromEntries( + Array.from({ length: 40 }, (_, index) => [`k${index}`, 'v']) + ); + await expect( + processVerifiedResendWebhook(executorWith({}).executor, { + providerEventId: 'msg_too_many_keys', + payload: event('email.delivered', tooMany), }) ).rejects.toThrow(/Invalid Resend webhook payload/u); - expect(harness.runTransaction).not.toHaveBeenCalled(); }); it('marks only a permanent bounce as a hard-bounce stop', async () => { diff --git a/libs/growth/src/lib/webhooks.ts b/libs/growth/src/lib/webhooks.ts index 3347ef6f7..d98ca3228 100644 --- a/libs/growth/src/lib/webhooks.ts +++ b/libs/growth/src/lib/webhooks.ts @@ -32,22 +32,6 @@ const DELIVERY_STATUS_PRECEDENCE: Readonly< suppressed: 3, complained: 4, }; -// Resend's wire payload gained `message_id` after the pinned SDK types were -// written, and transactional mail carries null broadcast/template ids. -const BASE_DATA_KEYS = new Set([ - 'broadcast_id', - 'created_at', - 'email_id', - 'from', - 'message_id', - 'subject', - 'tags', - 'template_id', - 'to', - 'bounce', - 'failed', - 'suppressed', -]); type SupportedResendEventType = | 'email.sent' @@ -169,11 +153,14 @@ function boundedRecord( ); } +const MAX_DATA_KEYS = 32; +const MAX_HEADER_ENTRIES = 32; + +// Known keys are validated; unknown keys are ignored rather than fatal, because +// Resend has added keys (message_id, headers) without notice and each addition +// used to turn every delivery event into a 400. The total key count stays bounded. function validateProviderBaseData(data: Record): void { - if ( - Object.keys(data).length > BASE_DATA_KEYS.size || - Object.keys(data).some((key) => !BASE_DATA_KEYS.has(key)) - ) { + if (Object.keys(data).length > MAX_DATA_KEYS) { throw new Error('Invalid Resend webhook payload'); } boundedDate(data['created_at']); @@ -195,6 +182,18 @@ function validateProviderBaseData(data: Record): void { if (data['message_id'] !== undefined && data['message_id'] !== null) { boundedText(data['message_id'], 998); } + if (data['headers'] !== undefined && data['headers'] !== null) { + const headers = data['headers']; + if (!Array.isArray(headers) || headers.length > MAX_HEADER_ENTRIES) { + throw new Error('Invalid Resend webhook payload'); + } + for (const header of headers) { + const entry = plainObject(header); + if (!entry) throw new Error('Invalid Resend webhook payload'); + boundedText(entry['name'], 128); + boundedText(entry['value'], 2_000); + } + } } function validateClosedDetails(