diff --git a/lib/emails/__tests__/processAndSendEmail.test.ts b/lib/emails/__tests__/processAndSendEmail.test.ts index 774998a3..58e781e2 100644 --- a/lib/emails/__tests__/processAndSendEmail.test.ts +++ b/lib/emails/__tests__/processAndSendEmail.test.ts @@ -39,6 +39,7 @@ describe("processAndSendEmail", () => { subject: "Test", html: expect.stringContaining("Hello world"), }), + undefined, ); }); @@ -56,6 +57,7 @@ describe("processAndSendEmail", () => { expect.objectContaining({ html: expect.stringContaining("

HTML body

"), }), + undefined, ); }); @@ -72,6 +74,7 @@ describe("processAndSendEmail", () => { expect.objectContaining({ cc: ["cc@example.com"], }), + undefined, ); }); @@ -91,6 +94,7 @@ describe("processAndSendEmail", () => { expect.objectContaining({ html: expect.stringContaining("Test Artist"), }), + undefined, ); }); @@ -129,4 +133,24 @@ describe("processAndSendEmail", () => { expect(result.error).toContain("Rate limited"); } }); + + it("forwards idempotencyKey to Resend so a replayed send delivers once (chat#1918)", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "resend-1" }); + await processAndSendEmail({ + to: ["a@b.com"], + subject: "S", + text: "body", + idempotencyKey: "chat_1:msg_2", + }); + expect(mockSendEmailWithResend).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ idempotencyKey: "chat_1:msg_2" }), + ); + }); + + it("omits Resend options entirely when no idempotencyKey is given", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "resend-2" }); + await processAndSendEmail({ to: ["a@b.com"], subject: "S", text: "body" }); + expect(mockSendEmailWithResend).toHaveBeenCalledWith(expect.anything(), undefined); + }); }); diff --git a/lib/emails/processAndSendEmail.ts b/lib/emails/processAndSendEmail.ts index f3e4a445..5c218317 100644 --- a/lib/emails/processAndSendEmail.ts +++ b/lib/emails/processAndSendEmail.ts @@ -14,6 +14,12 @@ export interface ProcessAndSendEmailInput { html?: string; headers?: Record; room_id?: string; + /** + * Makes the send exactly-once for a retried or replayed caller. Scheduled + * runs re-execute on crash or redeploy and would otherwise deliver the same + * report again (chat#1918). Forwarded to Resend, which dedupes on it. + */ + idempotencyKey?: string; } export interface ProcessAndSendEmailSuccess { @@ -39,7 +45,7 @@ export type ProcessAndSendEmailResult = ProcessAndSendEmailSuccess | ProcessAndS export async function processAndSendEmail( input: ProcessAndSendEmailInput, ): Promise { - const { to, cc = [], subject, text, html = "", headers = {}, room_id } = input; + const { to, cc = [], subject, text, html = "", headers = {}, room_id, idempotencyKey } = input; const roomData = room_id ? await selectRoomWithArtist(room_id) : null; const footer = getEmailFooter(room_id, roomData?.artist_name || undefined); @@ -51,14 +57,17 @@ export async function processAndSendEmail( // card, DESIGN.md font stack, and the existing footer as the layout footer. const htmlWithLayout = renderEmailLayout({ bodyHtml, footerHtml: footer }); - const result = await sendEmailWithResend({ - from: RECOUP_FROM_EMAIL, - to, - cc: cc.length > 0 ? cc : undefined, - subject, - html: htmlWithLayout, - headers, - }); + const result = await sendEmailWithResend( + { + from: RECOUP_FROM_EMAIL, + to, + cc: cc.length > 0 ? cc : undefined, + subject, + html: htmlWithLayout, + headers, + }, + idempotencyKey ? { idempotencyKey } : undefined, + ); if (result instanceof NextResponse) { const data = await result.json(); diff --git a/lib/emails/sendEmailHandler.ts b/lib/emails/sendEmailHandler.ts index 6fcc3ab5..767ece8b 100644 --- a/lib/emails/sendEmailHandler.ts +++ b/lib/emails/sendEmailHandler.ts @@ -30,6 +30,7 @@ export async function sendEmailHandler(request: NextRequest): Promise