Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions lib/emails/__tests__/processAndSendEmail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe("processAndSendEmail", () => {
subject: "Test",
html: expect.stringContaining("Hello world"),
}),
undefined,
);
});

Expand All @@ -56,6 +57,7 @@ describe("processAndSendEmail", () => {
expect.objectContaining({
html: expect.stringContaining("<h1>HTML body</h1>"),
}),
undefined,
);
});

Expand All @@ -72,6 +74,7 @@ describe("processAndSendEmail", () => {
expect.objectContaining({
cc: ["cc@example.com"],
}),
undefined,
);
});

Expand All @@ -91,6 +94,7 @@ describe("processAndSendEmail", () => {
expect.objectContaining({
html: expect.stringContaining("Test Artist"),
}),
undefined,
);
});

Expand Down Expand Up @@ -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);
});
});
27 changes: 18 additions & 9 deletions lib/emails/processAndSendEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export interface ProcessAndSendEmailInput {
html?: string;
headers?: Record<string, string>;
room_id?: string;
/**
* Makes the send exactly-once for a retried or replayed caller. Scheduled

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new comment overstates the guarantee: a replay after Resend’s 24-hour retention window can deliver again; describe this as deduplication within Resend’s 24-hour window or add durable deduplication for longer-lived retries.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/processAndSendEmail.ts, line 18:

<comment>The new comment overstates the guarantee: a replay after Resend’s 24-hour retention window can deliver again; describe this as deduplication within Resend’s 24-hour window or add durable deduplication for longer-lived retries.</comment>

<file context>
@@ -14,6 +14,12 @@ export interface ProcessAndSendEmailInput {
   headers?: Record<string, string>;
   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.
</file context>

* 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 {
Expand All @@ -39,7 +45,7 @@ export type ProcessAndSendEmailResult = ProcessAndSendEmailSuccess | ProcessAndS
export async function processAndSendEmail(
input: ProcessAndSendEmailInput,
): Promise<ProcessAndSendEmailResult> {
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);
Expand All @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: An explicitly supplied empty idempotency_key is silently downgraded to a non-idempotent send, allowing a retry to duplicate delivery; use an explicit undefined check here and reject empty keys in the request schema.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/processAndSendEmail.ts, line 69:

<comment>An explicitly supplied empty `idempotency_key` is silently downgraded to a non-idempotent send, allowing a retry to duplicate delivery; use an explicit `undefined` check here and reject empty keys in the request schema.</comment>

<file context>
@@ -51,14 +57,17 @@ export async function processAndSendEmail(
+      html: htmlWithLayout,
+      headers,
+    },
+    idempotencyKey ? { idempotencyKey } : undefined,
+  );
 
</file context>
Suggested change
idempotencyKey ? { idempotencyKey } : undefined,
idempotencyKey !== undefined ? { idempotencyKey } : undefined,

);

if (result instanceof NextResponse) {
const data = await result.json();
Expand Down
2 changes: 2 additions & 0 deletions lib/emails/sendEmailHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export async function sendEmailHandler(request: NextRequest): Promise<NextRespon
headers = {},
chat_id,
accountId,
idempotency_key,
} = validated.data;
const result = await processAndSendEmail({
to,
Expand All @@ -39,6 +40,7 @@ export async function sendEmailHandler(request: NextRequest): Promise<NextRespon
html,
headers,
room_id: chat_id,
idempotencyKey: idempotency_key,
});

if (result.success === false) {
Expand Down
4 changes: 4 additions & 0 deletions lib/emails/validateSendEmailBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export const sendEmailBodySchema = z
html: z.string().optional(),
headers: z.record(z.string(), z.string()).default({}).optional(),
chat_id: z.string().optional(),
idempotency_key: z

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new idempotency_key field is added to the validation schema but the validation test file doesn't cover it. The "all optional fields" test no longer exercises all optional fields now that idempotency_key exists, and there are no tests for the max(256) constraint or for accepting a valid key. Adding test coverage for the schema-level validation would catch regressions in the field's constraints.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/validateSendEmailBody.ts, line 22:

<comment>The new `idempotency_key` field is added to the validation schema but the validation test file doesn't cover it. The "all optional fields" test no longer exercises all optional fields now that `idempotency_key` exists, and there are no tests for the `max(256)` constraint or for accepting a valid key. Adding test coverage for the schema-level validation would catch regressions in the field's constraints.</comment>

<file context>
@@ -19,6 +19,10 @@ export const sendEmailBodySchema = z
     html: z.string().optional(),
     headers: z.record(z.string(), z.string()).default({}).optional(),
     chat_id: z.string().optional(),
+    idempotency_key: z
+      .string()
+      .max(256, "idempotency_key must be at most 256 characters")
</file context>

.string()
.max(256, "idempotency_key must be at most 256 characters")
.optional(),
account_id: z.string().uuid("account_id must be a valid UUID").optional(),
})
// Guard: never send an empty/footer-only email. A malformed or empty body
Expand Down
Loading