Skip to content
Merged
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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` });
Expand Down
38 changes: 34 additions & 4 deletions src/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
MessageAttachmentInput,
MessageTagInput,
SandboxResult,
SendBatchMailItem,
SendBatchMailResponse,
SendMailRequest,
SendMailRequestSettings,
Expand All @@ -25,6 +26,23 @@ export interface EmailMessage extends Omit<SendMailRequest, 'attachments'> {
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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<SendBatchMailResponse> {
this.#transport.assertAuth('emails.sendBatch', 'sending');
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Lettermint } from './lettermint';
export { Lettermint };
export type { LettermintOptions, LettermintShorthandOptions } from './lettermint';
export type {
BatchEmailMessage,
EmailAttachment,
EmailBuilder,
EmailMessage,
Expand Down
82 changes: 81 additions & 1 deletion test/emails.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
Expand Down Expand Up @@ -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(
Expand Down
Loading