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