From e7ca7639b77b6f5b65bcda4a0f61d7b49d2916f1 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sun, 18 Oct 2026 17:14:54 +0200 Subject: [PATCH] feat: add per-email idempotency keys to batch send Add the `BatchEmailMessage` type, an `EmailMessage` with an optional `idempotency_key`, and accept it in `emails.sendBatch()`. Each key is sent in its own batch item, separate from the request-level `idempotencyKey` option. A retried batch returns the original result of every email that was already accepted and sends only the rest. Builders get a key by spreading `builder.build()`. `send()` is unchanged. Document that a failed batch lists the emails accepted before the failure in `error.details.accepted`. Co-Authored-By: Claude --- README.md | 13 ++++++- src/emails.ts | 38 ++++++++++++++++++--- src/index.ts | 1 + test/emails.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 128 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5d68bcb..e3117fa 100644 --- a/README.md +++ b/README.md @@ -150,9 +150,20 @@ const results = await lettermint.emails.sendBatch([ ]); ``` +Give each email an `idempotency_key` to retry a batch safely. A retry returns the original result of every email that was already accepted and sends only the rest. Keys must be unique within the batch. Reusing a key with different content throws a `ConflictError` and sends nothing. For a builder, spread its `build()` output: + +```ts +await lettermint.emails.sendBatch([ + { ...invoice, idempotency_key: `invoice-${invoice.id}` }, + { ...welcome.to('john@example.com').build(), idempotency_key: 'welcome-john' }, +]); +``` + +Emails are accepted in order. If one fails, the emails before it may already be accepted; the thrown `ApiError` lists them in `error.details.accepted` (`{ index, message_id }` per email). + ### Idempotency -Pass an idempotency key to make retries safe. The API processes a key once, so a retry with the same key does not send the email again. The key applies only to the call it is passed to. +Pass an idempotency key to make retries safe. The API processes a key once, so a retry with the same key does not send the email again. The key applies only to the call it is passed to. For per-email keys in a batch, see [Batch sending](#batch-sending). ```ts await lettermint.emails.send(message, { idempotencyKey: `order-${order.id}-confirmation` }); diff --git a/src/emails.ts b/src/emails.ts index 3b704a1..8b3da87 100644 --- a/src/emails.ts +++ b/src/emails.ts @@ -4,6 +4,7 @@ import type { MessageAttachmentInput, MessageTagInput, SandboxResult, + SendBatchMailItem, SendBatchMailResponse, SendMailRequest, SendMailRequestSettings, @@ -25,6 +26,23 @@ export interface EmailMessage extends Omit { attachments?: EmailMessageAttachment[]; } +/** + * An email in {@link Emails.sendBatch}: an {@link EmailMessage} with an optional + * per-email `idempotency_key`. Only batches accept it; `send()` takes the + * `idempotencyKey` option instead. + */ +export interface BatchEmailMessage extends EmailMessage { + /** + * Makes this email safe to send again, in this or a later batch (1-255 + * characters, unique within the batch). If an email with the same key and + * content was accepted in the last 24 hours, the API returns its original + * result and does not send it again. Reusing a key with different content, + * or while that email is still being processed, rejects the whole batch with + * a `ConflictError` and sends nothing. + */ + idempotency_key?: SendBatchMailItem['idempotency_key']; +} + /** An attachment for {@link EmailBuilder.attach}. */ export interface EmailAttachment { filename: string; @@ -115,8 +133,8 @@ export function validateEmailMessage( } /** Returns a deep copy of the message in wire format, with binary attachment content base64-encoded. */ -function toWire(message: EmailMessage): SendMailRequest { - const copy = structuredCopy(message) as SendMailRequest; +function toWire(message: BatchEmailMessage): SendBatchMailItem { + const copy = structuredCopy(message) as SendBatchMailItem; if (message.attachments) { copy.attachments = message.attachments.map((attachment) => ({ ...attachment, @@ -314,9 +332,21 @@ export class Emails { }); } - /** Sends up to 500 emails in one request. Accepts messages and builders. */ + /** + * Sends up to 500 emails in one request. Accepts messages and builders. + * + * Give each message an `idempotency_key` to retry a batch safely: a retry + * returns the original result of every email that was accepted and sends only + * the rest. Keys must be unique within the batch; the API checks them. To give + * a builder a key, pass `{ ...builder.build(), idempotency_key }`. + * `options.idempotencyKey` is a separate key for the request as a whole. + * + * Emails are accepted in order. When one fails, the emails before it may + * already be accepted; the thrown `ApiError` then lists them in + * `details.accepted` (`{ index, message_id }` per email). + */ async sendBatch( - messages: (EmailMessage | EmailBuilder)[], + messages: (BatchEmailMessage | EmailBuilder)[], options?: SendOptions ): Promise { this.#transport.assertAuth('emails.sendBatch', 'sending'); diff --git a/src/index.ts b/src/index.ts index 337d500..09207fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { Lettermint } from './lettermint'; export { Lettermint }; export type { LettermintOptions, LettermintShorthandOptions } from './lettermint'; export type { + BatchEmailMessage, EmailAttachment, EmailBuilder, EmailMessage, diff --git a/test/emails.test.ts b/test/emails.test.ts index 947de81..22acf3e 100644 --- a/test/emails.test.ts +++ b/test/emails.test.ts @@ -1,5 +1,11 @@ import { inspect } from 'node:util'; -import { type EmailMessage, LettermintValidationError } from '../src'; +import { + type BatchEmailMessage, + ConflictError, + type EmailMessage, + LettermintValidationError, + ServerError, +} from '../src'; import { SENDING_TOKEN, TEAM_TOKEN, client, delay, json, rejection, thrown } from './helpers'; const accepted = (id = 'message_1') => json(202, { message_id: id, status: 'pending' }); @@ -157,6 +163,80 @@ describe('emails.sendBatch', () => { expect(requests[1].headers).not.toHaveProperty('Idempotency-Key'); }); + it('sends per-email idempotency keys in the items, apart from the request key', async () => { + const { lettermint, requests } = client({}, () => + json(202, [ + { message_id: 'm1', status: 'pending' }, + { message_id: 'm2', status: 'pending' }, + { message_id: 'm3', status: 'pending' }, + ]) + ); + const plain: EmailMessage = { from: 'a@example.test', to: ['b@example.test'], subject: 'One' }; + const keyed: BatchEmailMessage = { ...plain, subject: 'Two', idempotency_key: 'order-1' }; + const builder = lettermint.emails.compose().from('a@example.test').to('c@example.test'); + const messages: EmailMessage[] = [plain]; + await lettermint.emails.sendBatch( + [...messages, keyed, { ...builder.subject('Three').build(), idempotency_key: 'order-2' }], + { idempotencyKey: 'batch-1' } + ); + expect(requests[0].body).toEqual([ + plain, + { ...plain, subject: 'Two', idempotency_key: 'order-1' }, + { + from: 'a@example.test', + to: ['c@example.test'], + subject: 'Three', + idempotency_key: 'order-2', + }, + ]); + expect(requests[0].headers['Idempotency-Key']).toBe('batch-1'); + + await lettermint.emails.sendBatch([keyed]); + expect(requests[1].body).toEqual([{ ...plain, subject: 'Two', idempotency_key: 'order-1' }]); + expect(requests[1].headers).not.toHaveProperty('Idempotency-Key'); + }); + + it('does not take a per-email idempotency key on a single send', async () => { + const { lettermint, requests } = sendingClient(); + const message: EmailMessage = { + from: 'a@example.test', + to: ['b@example.test'], + subject: 'x', + // @ts-expect-error idempotency_key is batch-only; send() takes the idempotencyKey option + idempotency_key: 'order-1', + }; + expect(message).toBeDefined(); + await lettermint.emails.send( + { from: 'a@example.test', to: ['b@example.test'], subject: 'x' }, + { idempotencyKey: 'order-1' } + ); + expect(requests[0].body).not.toHaveProperty('idempotency_key'); + expect(requests[0].headers['Idempotency-Key']).toBe('order-1'); + }); + + it('exposes an idempotency conflict and the emails accepted before a failure', async () => { + const message = { from: 'a@example.test', to: ['b@example.test'], subject: 'x' }; + const conflict = { + error: { code: 'IDEMPOTENCY_CONFLICT', message: 'The idempotency key was reused.' }, + }; + const accepted = { accepted: [{ index: 0, message_id: 'm1' }] }; + const { lettermint } = client({}, (_, index) => + index === 0 + ? json(409, conflict) + : json(500, { error: { code: 'INTERNAL_ERROR', message: 'Failed.', details: accepted } }) + ); + const batch = [ + { ...message, idempotency_key: 'k1' }, + { ...message, idempotency_key: 'k2' }, + ]; + const first = await rejection(lettermint.emails.sendBatch(batch)); + expect(first).toBeInstanceOf(ConflictError); + expect(first).toMatchObject({ status: 409, code: 'IDEMPOTENCY_CONFLICT' }); + const second = await rejection(lettermint.emails.sendBatch(batch)); + expect(second).toBeInstanceOf(ServerError); + expect((second as ServerError).details).toEqual(accepted); + }); + it('names the invalid message', async () => { const { lettermint, fetch } = sendingClient(); const error = (await rejection(