@nxgt/mail 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  # Sending
2
2
 
3
3
  This page is for calling `mailer.send`: the shape of what it takes, the
4
- addresses, headers and attachments it accepts, what it answers, and what it
5
- throws.
4
+ addresses, headers and attachments it accepts, one-click unsubscribe, the
5
+ idempotency key that makes a retry safe, what it answers, and what it throws.
6
6
 
7
7
  ```ts
8
8
  import { createMemoryMailer } from '@nxgt/mail';
@@ -75,6 +75,7 @@ interface MailMessage extends Rendered {
75
75
  readonly replyTo?: Address;
76
76
  readonly headers?: Readonly<Record<string, string>>;
77
77
  readonly attachments?: readonly MailAttachment[];
78
+ readonly idempotencyKey?: string;
78
79
  }
79
80
 
80
81
  interface MailAttachment {
@@ -92,8 +93,9 @@ interface MailAttachment {
92
93
  | `to` | `Address \| readonly Address[]` | yes | One recipient or several, at least one |
93
94
  | `from` | `Address` | no | The sender. `checkMessage` does not require one: a transport is usually wired with a default sender, and one without a default may refuse a message without `from` — see its documentation |
94
95
  | `replyTo` | `Address` | no | Where replies go |
95
- | `headers` | `Record<string, string>` | no | Extra headers, such as `List-Unsubscribe` |
96
+ | `headers` | `Record<string, string>` | no | Extra headers, such as `X-Entity-Ref-ID`, or the two of [one-click unsubscribe](#one-click-unsubscribe) |
96
97
  | `attachments` | `readonly MailAttachment[]` | no | Files sent with the e-mail, in order, as bytes — see [Attachments](#attachments). An empty list is the same as none |
98
+ | `idempotencyKey` | `string` | no | Names this send, so sending it again delivers it once where the transport can deduplicate — see [Idempotency](#idempotency--sending-once). 1 to 256 visible ASCII characters |
97
99
 
98
100
  `Rendered` is what the renderer answers — `mails.render('verify-email', { name, link })`
99
101
  fills the values only known at send time into a built Maizzle template — and a
@@ -209,13 +211,14 @@ declare const rendered: Rendered;
209
211
  const message: MailMessage = {
210
212
  ...rendered,
211
213
  to: 'ada@example.com',
212
- headers: {
213
- 'List-Unsubscribe': '<https://example.com/unsubscribe?u=42>',
214
- 'X-Entity-Ref-ID': 'welcome-42',
215
- },
214
+ headers: { 'X-Entity-Ref-ID': 'welcome-42' },
216
215
  };
217
216
  ```
218
217
 
218
+ `List-Unsubscribe` and `List-Unsubscribe-Post` are headers like any other,
219
+ but write them with [`listUnsubscribe`](#one-click-unsubscribe), which checks
220
+ the URL.
221
+
219
222
  | Written | Answer |
220
223
  | --- | --- |
221
224
  | `{ 'X Bad': 'v' }` | `MailRefused`: `send: a header name must be letters, digits and hyphens` |
@@ -223,6 +226,213 @@ const message: MailMessage = {
223
226
  | `{ Bcc: 'eve@example.com' }` | `MailRefused`: `send: header Bcc is reserved — addresses, the subject and the MIME structure are never custom headers` |
224
227
  | `{ 'content-type': 'text/plain' }` | `MailRefused`: `send: header content-type is reserved — …` |
225
228
 
229
+ ## One-click unsubscribe
230
+
231
+ `listUnsubscribe` answers the two headers that give an e-mail the
232
+ "Unsubscribe" button Gmail and Yahoo show next to the sender (RFC 8058, with
233
+ RFC 2369's `List-Unsubscribe`), to spread into `headers`:
234
+
235
+ ```ts
236
+ import { listUnsubscribe } from '@nxgt/mail';
237
+
238
+ listUnsubscribe({ url: 'https://example.com/unsubscribe?token=s3cr3t' });
239
+ // {
240
+ // 'List-Unsubscribe': '<https://example.com/unsubscribe?token=s3cr3t>',
241
+ // 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
242
+ // }
243
+
244
+ listUnsubscribe({ url: 'https://example.com/unsubscribe?token=s3cr3t', mailto: 'unsubscribe@example.com' });
245
+ // 'List-Unsubscribe': '<https://example.com/unsubscribe?token=s3cr3t>, <mailto:unsubscribe@example.com>'
246
+ ```
247
+
248
+ ```ts
249
+ interface ListUnsubscribeOptions {
250
+ readonly url: string;
251
+ readonly mailto?: string;
252
+ }
253
+
254
+ // A type, not an interface, so it goes into `headers` as it is.
255
+ type ListUnsubscribeHeaders = {
256
+ readonly 'List-Unsubscribe': string;
257
+ readonly 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click';
258
+ };
259
+
260
+ function listUnsubscribe(options: ListUnsubscribeOptions): ListUnsubscribeHeaders;
261
+ ```
262
+
263
+ | Option | Type | Default | Effect |
264
+ | --- | --- | --- | --- |
265
+ | `url` | `string` | required | The `https:` URL a mail client POSTs `List-Unsubscribe=One-Click` to. One per recipient, carrying what identifies them — `https://example.com/unsubscribe?token=…`. Written first in `List-Unsubscribe` |
266
+ | `mailto` | `string` | none | A bare address that unsubscribes whoever writes to it, for clients that only send mail. Written after the URL as `<mailto:…>` |
267
+
268
+ The headers travel as any other: `checkMessage` accepts them, and every
269
+ transport sends them unchanged. Spread them into `headers` — alone, as
270
+ `headers: { ...listUnsubscribe({ url }) }`, or beside headers of your own:
271
+
272
+ ```ts
273
+ import { listUnsubscribe, type MailMessage, type Rendered } from '@nxgt/mail';
274
+
275
+ declare const rendered: Rendered;
276
+ declare const token: string;
277
+
278
+ const message: MailMessage = {
279
+ ...rendered,
280
+ to: 'ada@example.com',
281
+ headers: {
282
+ ...listUnsubscribe({ url: `https://example.com/unsubscribe?token=${encodeURIComponent(token)}` }),
283
+ 'X-Entity-Ref-ID': 'newsletter-2026-09',
284
+ },
285
+ };
286
+ ```
287
+
288
+ ### What Gmail and Yahoo require
289
+
290
+ Since 2024, a sender of bulk mail to Gmail or Yahoo addresses must, on
291
+ marketing and subscribed mail:
292
+
293
+ - carry **both** headers, `List-Unsubscribe` with an `https:` URL and
294
+ `List-Unsubscribe-Post: List-Unsubscribe=One-Click`;
295
+ - have them covered by a **DKIM signature** of the sending domain, so nobody
296
+ can add or change them on the way;
297
+ - honour the unsubscribe promptly — within two days.
298
+
299
+ The DKIM signature is the sender's, not this package's: `listUnsubscribe`
300
+ writes the headers, and whatever signs the message must include them.
301
+
302
+ | Transport | Who signs |
303
+ | --- | --- |
304
+ | `@nxgt/mail-resend` | Resend, with the DKIM key of your verified domain. Check that its `h=` names both headers in a received message's `DKIM-Signature` |
305
+ | `@nxgt/mail-smtp` | Your relay, or nodemailer's own `dkim` option on the transporter you create. A relay that does not DKIM-sign leaves the headers unsigned, and the message fails the requirement |
306
+
307
+ ### Which e-mails carry it
308
+
309
+ **Marketing and bulk mail** — a newsletter, a digest, a product announcement,
310
+ anything the recipient subscribed to and can stop receiving.
311
+
312
+ **Not transactional mail** — a password reset, a sign-in code, an e-mail
313
+ verification, a receipt, a security alert. The recipient cannot opt out of
314
+ those, and an "Unsubscribe" button next to a sign-in code invites them to
315
+ try. Leave `headers` without it.
316
+
317
+ ### The endpoint
318
+
319
+ The URL is yours. It must:
320
+
321
+ - **unsubscribe on a `POST`** whose form body is `List-Unsubscribe=One-Click`
322
+ — sent as `application/x-www-form-urlencoded` or `multipart/form-data`,
323
+ which `request.formData()` both reads;
324
+ - do it **with no login, no confirmation page and no redirect**, from the
325
+ URL alone — the mail client sends no cookie, so the endpoint takes no CSRF
326
+ token either — and answer **2xx**;
327
+ - on a **`GET`** — the same URL in the body of the e-mail, clicked by a
328
+ person — **show a page, never unsubscribe**: link scanners and previews
329
+ fetch every URL in an e-mail. The page's button can post the same form.
330
+
331
+ ```ts
332
+ // Yours: the token store.
333
+ declare function unsubscribeByToken(token: string): Promise<boolean>; // false: unknown token
334
+
335
+ export async function unsubscribeHandler(request: Request): Promise<Response> {
336
+ const token = new URL(request.url).searchParams.get('token') ?? '';
337
+
338
+ if (request.method === 'POST') {
339
+ const form = await request.formData();
340
+ if (form.get('List-Unsubscribe') !== 'One-Click') return new Response(null, { status: 400 });
341
+ await unsubscribeByToken(token); // an unknown token answers 200 too: nothing to tell a mail client
342
+ return new Response(null, { status: 200 });
343
+ }
344
+
345
+ // GET: a person followed the link in the e-mail. Show, do not act.
346
+ const page = `<!doctype html><title>Unsubscribe</title>
347
+ <form method="post"><input type="hidden" name="List-Unsubscribe" value="One-Click">
348
+ <button>Unsubscribe</button></form>`;
349
+ return new Response(page, { headers: { 'content-type': 'text/html; charset=utf-8' } });
350
+ }
351
+ ```
352
+
353
+ The form posts to its own URL, token included, so the page and the mail
354
+ client take the same path. In a framework, mount it at the URL you pass:
355
+ with Hono, `app.on(['GET', 'POST'], '/unsubscribe', (c) => unsubscribeHandler(c.req.raw))`.
356
+
357
+ ### The token is a credential
358
+
359
+ Anyone holding the URL can unsubscribe that recipient. Make the token
360
+ unguessable (random, or signed), keep it valid for as long as the e-mail may
361
+ be read, and **never log it** — nor the URL that carries it, nor the query
362
+ string of the endpoint's access log. `listUnsubscribe` does its part: a
363
+ refused `url` is reported by the rule it broke, never quoted.
364
+
365
+ ### Commas must be percent-encoded
366
+
367
+ `List-Unsubscribe` is a comma-separated list of URLs: a raw `,` in the URL
368
+ would start a second one, so it is refused. `encodeURIComponent` and
369
+ `URLSearchParams` both write `%2C`; a URL built with `URL` is passed as
370
+ `url.href` — a `URL` object is a compile error:
371
+
372
+ ```ts
373
+ import { listUnsubscribe } from '@nxgt/mail';
374
+
375
+ declare const token: string;
376
+
377
+ const url = new URL('https://example.com/unsubscribe');
378
+ url.searchParams.set('token', token);
379
+ url.searchParams.set('lists', 'news,offers'); // written lists=news%2Coffers
380
+
381
+ listUnsubscribe({ url: url.href });
382
+ ```
383
+
384
+ ### Refusals
385
+
386
+ The URL and the address are checked when the headers are built, before
387
+ anything is sent. A `MailRefused` names the rule, never the value:
388
+
389
+ | Written | Answer |
390
+ | --- | --- |
391
+ | `url: 'https://example.com/u?token=…'`, `'https://example.com:8443/u?list=a%2Cb'` | accepted |
392
+ | `url: 'http://example.com/u'`, `'mailto:u@example.com'`, `'/unsubscribe'`, `''`, `'https://user:pass@example.com/u'`, `'https://exämple.com/u'` | `MailRefused`: `listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma` |
393
+ | a `url` holding a space, a tab, a line break, a character outside ASCII, `<`, `>`, a double quote, a backtick, a backslash, a brace, `|`, `^` or a raw `,` | `MailRefused`: the same message |
394
+ | `mailto: 'Unsub <u@example.com>'`, `'u@example.com, v@example.com'`, `'unsubscribe'`, `'mailto:u@example.com'`, `'u@example.com?subject=x'`, `'ü@example.com'` | `MailRefused`: `listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com` |
395
+ | `listUnsubscribe(null)` | `TypeError`: `listUnsubscribe: options must be an object, as { url }` |
396
+ | `url: new URL(…)` | a compile error; at run time `TypeError`: `listUnsubscribe: url must be a string` |
397
+ | `mailto: 42` | a compile error; at run time `TypeError`: `listUnsubscribe: mailto must be a string` |
398
+
399
+ A `TypeError` is a mistake in the code, not in the data: no request handler
400
+ should answer one. A `MailRefused` usually means a URL built from a value that
401
+ was not encoded — keep the call inside the `try` that handles `MailError`.
402
+
403
+ ### A realistic case — a newsletter, one URL per subscriber
404
+
405
+ ```ts
406
+ import { listUnsubscribe, MailError, type Mailer } from '@nxgt/mail';
407
+ import { createMailRenderer } from '@nxgt/mail/renderer';
408
+
409
+ // Yours: the subscriber store.
410
+ declare function subscribersOf(list: string): AsyncIterable<{ id: string; email: string; name: string; token: string }>;
411
+
412
+ const mails = createMailRenderer({ dir: 'dist' });
413
+
414
+ export async function sendIssue(mailer: Mailer, issue: string): Promise<{ sent: number; failed: string[] }> {
415
+ let sent = 0;
416
+ const failed: string[] = [];
417
+ for await (const subscriber of subscribersOf('news')) {
418
+ const unsubscribe = `https://example.com/unsubscribe?token=${encodeURIComponent(subscriber.token)}`;
419
+ try {
420
+ await mailer.send({
421
+ to: subscriber.email,
422
+ ...mails.render('newsletter', { name: subscriber.name, unsubscribe }), // the link in the body
423
+ headers: { ...listUnsubscribe({ url: unsubscribe }) }, // the button in the mail client
424
+ idempotencyKey: `newsletter-${issue}/${subscriber.id}`,
425
+ });
426
+ sent += 1;
427
+ } catch (error) {
428
+ if (!(error instanceof MailError)) throw error;
429
+ failed.push(subscriber.id); // an id, never the address or the token
430
+ }
431
+ }
432
+ return { sent, failed };
433
+ }
434
+ ```
435
+
226
436
  ## Attachments
227
437
 
228
438
  An attachment is a file's **bytes**, its name and its type:
@@ -302,6 +512,91 @@ carry — is a `MailRefused` from the transport (an SMTP `552`; a Resend `400`,
302
512
  `413` or `422`), and sending it again unchanged fails again: send a link
303
513
  instead.
304
514
 
515
+ ## Idempotency — sending once
516
+
517
+ A send that fails with `MailFailure` may still have reached the provider: a
518
+ timeout, a dropped connection. Retrying it can deliver the e-mail twice.
519
+ `idempotencyKey` names the send, so a transport that can deduplicate delivers
520
+ it once however often it is sent:
521
+
522
+ ```ts
523
+ import type { Mailer, Rendered } from '@nxgt/mail';
524
+
525
+ export async function sendReceipt(mailer: Mailer, order: { id: string; email: string }, rendered: Rendered): Promise<void> {
526
+ await mailer.send({
527
+ ...rendered,
528
+ to: order.email,
529
+ idempotencyKey: `order-${order.id}/receipt`, // the same for every retry of this receipt
530
+ });
531
+ }
532
+ ```
533
+
534
+ **Derive the key from what the e-mail is about** — an order id, a user id
535
+ and a purpose (`user-7/welcome`, `invitation-19`) — **never from the time or
536
+ a random value**: a retry would carry a new key, and deliver again. **A key
537
+ names one e-mail**: two different e-mails about the same thing need two keys
538
+ (`order-42/receipt`, `order-42/shipped`) — a transport that deduplicates
539
+ refuses a different message under a key it already delivered.
540
+
541
+ ```ts
542
+ declare const order: { id: string };
543
+ declare const userId: string;
544
+ declare const resetRequestId: string;
545
+
546
+ const receipt = `order-${order.id}/receipt`; // one receipt per order
547
+ const welcome = `user-${userId}/welcome`; // one welcome per user
548
+ const reset = `password-reset/${resetRequestId}`; // per request, not per user: a second request is a second e-mail
549
+
550
+ const wrong = `receipt-${Date.now()}`; // a retry gets a new key, and delivers again
551
+ const wrongToo = crypto.randomUUID(); // the same
552
+ ```
553
+
554
+ **What each transport does with it:**
555
+
556
+ | Transport | Effect |
557
+ | --- | --- |
558
+ | `createMemoryMailer()` | The same message under a key it already delivered answers that delivery's `messageId`, and delivers nothing more; a different message under that key is a `MailRefused`. A send that failed leaves its key free. See [Testing — idempotency](testing.md#idempotencykey--a-retry-delivers-once) |
559
+ | `@nxgt/mail-resend` | Sent as Resend's `Idempotency-Key` header. Resend keeps a key for 24 hours: a retry within them answers the first send's id and delivers nothing more; the same key with a different message is refused (`MailRefused`) |
560
+ | `@nxgt/mail-smtp` | Ignored: SMTP has no such mechanism, so a message sent twice is delivered twice |
561
+
562
+ A transport that cannot deduplicate ignores the key; it never refuses the
563
+ message for carrying one. So a key is always safe to set, and only makes a
564
+ retry safe where the transport honours it. It is never written into the
565
+ e-mail itself.
566
+
567
+ `checkMessage` refuses a key that is not 1 to 256 visible ASCII characters —
568
+ empty, too long, holding a space, a line break or an accented letter, or not
569
+ a string — without quoting it:
570
+
571
+ | Written | Answer |
572
+ | --- | --- |
573
+ | `'order-42/receipt'`, `'a:b_c.d~e'`, `'k'.repeat(256)` | accepted |
574
+ | `''`, `'k'.repeat(257)`, `'order 42'`, `'commande-42-reçu'`, `'order-42\r\nX-Evil: 1'`, `42` | `MailRefused`: `send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt` |
575
+
576
+ A transport that deduplicates also refuses **a different message** under a
577
+ key it already delivered, with `MailRefused` — the memory mailer with
578
+ `send: idempotencyKey was already used for a different message — a key names one e-mail`,
579
+ Resend's transport with `send: Resend refused the message` on its `409`.
580
+ Sending it again fails again: give that e-mail its own key.
581
+
582
+ A retry from a queue, with the key the job carries:
583
+
584
+ ```ts
585
+ import { MailFailure, type Mailer, type MailMessage } from '@nxgt/mail';
586
+
587
+ // Yours: the queue that runs a job again later.
588
+ declare function retryLater(job: { message: MailMessage }, delaySeconds: number): Promise<void>;
589
+
590
+ export async function runSendJob(mailer: Mailer, job: { message: MailMessage }): Promise<void> {
591
+ try {
592
+ await mailer.send(job.message); // job.message.idempotencyKey was set when the job was queued
593
+ } catch (error) {
594
+ if (error instanceof MailFailure) return retryLater(job, 60); // the same key: delivered once, where the transport deduplicates
595
+ throw error; // MailRefused: sending it again fails again
596
+ }
597
+ }
598
+ ```
599
+
305
600
  ## Errors
306
601
 
307
602
  ```ts
@@ -325,8 +620,8 @@ class MailRefused extends MailError {
325
620
 
326
621
  | Code | Class | When | Sending it again |
327
622
  | --- | --- | --- | --- |
328
- | `MAIL_FAILED` | `MailFailure` | The transport could not hand the e-mail over: a refused connection, a timeout, a 5xx from the provider, an expired credential. The transport's error is the `cause`. **Nothing is known to have been sent**: after a timeout or a dropped connection the provider may have taken it all the same | May work later. Never report it as sent |
329
- | `MAIL_REFUSED` | `MailRefused` | The e-mail itself was refused, before or by the transport: no recipient, something that is not an address, a line break in the subject or a header, a reserved header, an attachment that is not bytes or is badly named, or the provider answering that the message is malformed or too large | Fails again, unchanged |
623
+ | `MAIL_FAILED` | `MailFailure` | The transport could not hand the e-mail over: a refused connection, a timeout, a 5xx from the provider, an expired credential. The transport's error is the `cause`. **Nothing is known to have been sent**: after a timeout or a dropped connection the provider may have taken it all the same | May work later — with an [`idempotencyKey`](#idempotency--sending-once), without a second delivery where the transport deduplicates. Never report it as sent |
624
+ | `MAIL_REFUSED` | `MailRefused` | The e-mail itself was refused, before or by the transport: no recipient, something that is not an address, a line break in the subject or a header, a reserved header, an attachment that is not bytes or is badly named, an unsubscribe URL that is not `https:` or holds a raw comma, a malformed idempotency key or one already used for a different message, or the provider answering that the message is malformed or too large | Fails again, unchanged |
330
625
 
331
626
  `MailError` is **abstract**: catch it, test `instanceof MailError`, but
332
627
  `new MailError(…)` does not compile — a bare one would pass a `code` check and
@@ -143,18 +143,105 @@ await mailer
143
143
  mailer.attempts; // 0 — and the failure is still queued for the next well-formed send
144
144
  ```
145
145
 
146
+ ## `idempotencyKey` — a retry delivers once
147
+
148
+ The memory mailer honours a message's
149
+ [`idempotencyKey`](sending.md#idempotency--sending-once) as Resend does:
150
+
151
+ - the **same message** under a key it already delivered answers that
152
+ delivery's `messageId`, and nothing more reaches `sent`;
153
+ - a **different message** under that key — another subject, another
154
+ recipient, other attachment bytes, any field — is refused with
155
+ `MailRefused`: `send: idempotencyKey was already used for a different message — a key names one e-mail`,
156
+ as Resend answers `409 invalid_idempotent_request`;
157
+ - a send that **failed** delivered nothing, so its key stays free, and the
158
+ retry delivers;
159
+ - each send still counts in `attempts`, the answered duplicate and the
160
+ refused reuse included: both reached the hand-over;
161
+ - `clear()` forgets the keys.
162
+
163
+ A test proving that a job run twice e-mails once:
164
+
165
+ ```ts
166
+ import { expect, it } from 'bun:test';
167
+ import { createMemoryMailer, type Mailer, MailRefused } from '@nxgt/mail';
168
+
169
+ // The code under test: a job that may run more than once for the same order.
170
+ async function sendReceipt(mailer: Mailer, orderId: string): Promise<string | null> {
171
+ const { messageId } = await mailer.send({
172
+ to: 'ada@example.com',
173
+ subject: 'Your receipt',
174
+ html: '<p>Thank you for your order.</p>',
175
+ text: 'Thank you for your order.',
176
+ idempotencyKey: `order-${orderId}/receipt`,
177
+ });
178
+ return messageId;
179
+ }
180
+
181
+ it('sends one receipt, however often the job runs', async () => {
182
+ const mailer = createMemoryMailer();
183
+
184
+ const first = await sendReceipt(mailer, '42');
185
+ const again = await sendReceipt(mailer, '42');
186
+
187
+ expect(again).toBe(first); // 'memory-1', both times
188
+ expect(mailer.sent).toHaveLength(1);
189
+ expect(mailer.attempts).toBe(2);
190
+ });
191
+
192
+ it('refuses another e-mail under the same key', async () => {
193
+ const mailer = createMemoryMailer();
194
+ await sendReceipt(mailer, '42');
195
+
196
+ const error = await mailer
197
+ .send({
198
+ to: 'ada@example.com',
199
+ subject: 'Your order has shipped', // another e-mail, the receipt's key
200
+ html: '<p>Your order has shipped.</p>',
201
+ text: 'Your order has shipped.',
202
+ idempotencyKey: 'order-42/receipt',
203
+ })
204
+ .then(() => null, (e: unknown) => e);
205
+
206
+ expect(error).toBeInstanceOf(MailRefused); // give it its own key: order-42/shipped
207
+ expect(mailer.sent).toHaveLength(1);
208
+ });
209
+
210
+ it('delivers the retry of a send that failed', async () => {
211
+ const mailer = createMemoryMailer();
212
+ mailer.failNext();
213
+
214
+ await sendReceipt(mailer, '42').then(() => null, (e: unknown) => e); // MailFailure
215
+ await sendReceipt(mailer, '42');
216
+
217
+ expect(mailer.sent).toHaveLength(1);
218
+ expect(mailer.sent[0]?.idempotencyKey).toBe('order-42/receipt');
219
+ });
220
+ ```
221
+
222
+ "The same message" is what it would deliver: every field of `MailMessage`,
223
+ read by name, the attachments by their bytes. How the object was written
224
+ does not count — the order of its fields or of its headers, `to` as one
225
+ address or a list of one, no `headers` or `attachments` or an empty one,
226
+ the bytes in a `Buffer` or a plain `Uint8Array` — and neither does a field
227
+ `MailMessage` does not have. SMTP ignores
228
+ the key altogether, so this is the behaviour of a deduplicating transport,
229
+ not of every one. A queued `failNext` fails the next send even when its key
230
+ was already delivered.
231
+
146
232
  ## `clear()`
147
233
 
148
- Forgets the outbox, the attempts, and any queued failure. The id counter keeps
149
- going, so an id is never reused within one mailer.
234
+ Forgets the outbox, the attempts, any queued failure, and the idempotency
235
+ keys. The id counter keeps going, so an id is never reused within one mailer.
150
236
 
151
237
  ## What it refuses
152
238
 
153
239
  Exactly what every transport refuses, because it calls
154
240
  [`checkMessage`](transports.md#checkmessage-first) first: no recipient, something
155
241
  that is not an address, a line break in a name, the subject or a header, a
156
- missing part, an attachment that is not bytes, or whose file name or type is
157
- malformed. A test that passes against the memory mailer does not pass by
242
+ missing part, an attachment that is not bytes or whose file name or type is
243
+ malformed, or an idempotency key that is not 1 to 256 visible ASCII
244
+ characters. A test that passes against the memory mailer does not pass by
158
245
  accident a message a real transport would refuse. The full list is in
159
246
  [Sending](sending.md#addresses) and
160
247
  [Sending — attachments](sending.md#attachments).
@@ -54,9 +54,12 @@ A transport:
54
54
  type — base64 in a JSON body, a MIME part over SMTP, the name encoded when
55
55
  it is not ASCII — and **never reads a file or fetches a URL** to attach
56
56
  one: `MailAttachment` holds bytes only;
57
- 7. **never retries in secret**, never resolves `false`, never logs and
57
+ 7. **uses `idempotencyKey` if the provider deduplicates, and ignores it
58
+ otherwise** — see [The idempotency key](#the-idempotency-key). It never
59
+ refuses a message for carrying one, and never writes it into the e-mail;
60
+ 8. **never retries in secret**, never resolves `false`, never logs and
58
61
  resolves;
59
- 8. **defines no error class of its own**. It throws the classes imported from
62
+ 9. **defines no error class of its own**. It throws the classes imported from
60
63
  `@nxgt/mail`, declared as a required peer, so `error instanceof MailFailure`
61
64
  holds in the application whichever transport threw it. `MailError` is
62
65
  abstract, so a bare one cannot be thrown:
@@ -64,14 +67,15 @@ A transport:
64
67
  ```json
65
68
  {
66
69
  "peerDependencies": {
67
- "@nxgt/mail": "^0.2.0"
70
+ "@nxgt/mail": "^0.3.0"
68
71
  }
69
72
  }
70
73
  ```
71
74
 
72
- On `0.x`, a caret covers one minor: `^0.2.0` is `>=0.2.0 <0.3.0`. Declare the
75
+ On `0.x`, a caret covers one minor: `^0.3.0` is `>=0.3.0 <0.4.0`. Declare the
73
76
  minor whose `MailMessage` your transport reads — `0.2` is the one with
74
- `attachments` — and release your transport when `@nxgt/mail` moves to the next.
77
+ `attachments`, `0.3` the one with `idempotencyKey` — and release your
78
+ transport when `@nxgt/mail` moves to the next.
75
79
 
76
80
  An error's `message` reports a shape, never a value: never an address, a
77
81
  subject, a link, an API key or a connection string. What the provider said goes
@@ -105,9 +109,12 @@ Throws `MailRefused`, naming **where** the problem is and never the value:
105
109
  | an attachment whose `content` is not a `Uint8Array` — a string, a path, an `ArrayBuffer` | `send: attachments[0].content must be a Uint8Array — the file's bytes, never a path or a URL` |
106
110
  | a file name that is empty, `.` or `..`, or holds `/`, `\`, a line break, a control character or a format character | `send: attachments[0].filename must be a file name — not empty, not . or .., without / or \, a line break or a control character` |
107
111
  | a content type that is not a bare `type/subtype`, or is `multipart/*` or `message/*` | `send: attachments[0].contentType must be a file's type/subtype, as application/pdf — never multipart/* or message/*` |
112
+ | an `idempotencyKey` that is not 1 to 256 visible ASCII characters — empty, a space, a line break, a letter outside ASCII, not a string | `send: idempotencyKey must be 1 to 256 visible ASCII characters, as order-42/receipt` |
108
113
 
109
114
  An empty `attachments` is accepted, and is the same as none: send no
110
- attachment field to the provider then.
115
+ attachment field to the provider then. A key that passes is safe to write in
116
+ an HTTP header as it is: no line break, no character a header would need to
117
+ encode.
111
118
 
112
119
  Two helpers turn addresses into what a provider wants:
113
120
 
@@ -153,6 +160,8 @@ export function createHttpMailer(options: HttpMailerOptions): Mailer {
153
160
  return {
154
161
  async send(message) {
155
162
  checkMessage(message);
163
+ // The key names the send; it is not part of the e-mail, so it stays out of the body.
164
+ const { idempotencyKey, ...fields } = message;
156
165
  // A JSON API takes an attachment's bytes as base64.
157
166
  // An empty list is none: the field is left out of the request.
158
167
  const attachments = message.attachments?.length
@@ -167,8 +176,13 @@ export function createHttpMailer(options: HttpMailerOptions): Mailer {
167
176
  try {
168
177
  response = await post(options.endpoint, {
169
178
  method: 'POST',
170
- headers: { authorization: `Bearer ${options.apiKey}`, 'content-type': 'application/json' },
171
- body: JSON.stringify({ ...message, from: message.from ?? options.from, attachments }),
179
+ headers: {
180
+ authorization: `Bearer ${options.apiKey}`,
181
+ 'content-type': 'application/json',
182
+ // This provider deduplicates on a header; with one that does not, leave the key out.
183
+ ...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }),
184
+ },
185
+ body: JSON.stringify({ ...fields, from: message.from ?? options.from, attachments }),
172
186
  });
173
187
  } catch (cause) {
174
188
  throw new MailFailure('send: the provider could not be reached', { cause });
@@ -190,6 +204,51 @@ export function createHttpMailer(options: HttpMailerOptions): Mailer {
190
204
  }
191
205
  ```
192
206
 
207
+ ## The idempotency key
208
+
209
+ `idempotencyKey` names a send, so that sending the same message again — a
210
+ retry after a timeout, a job run twice — delivers it once. What a transport
211
+ does with it depends on its provider:
212
+
213
+ | The provider | The transport |
214
+ | --- | --- |
215
+ | deduplicates requests on a key — an `Idempotency-Key` header, a field of its API | passes the key there, as it is. A repeated key answers the first send's id: resolve with it, as for any hand-over. Map the provider's answers: a key reused for a **different** message is a `MailRefused` (sending it again fails again); a key whose first send is **still in progress** is a `MailFailure` (a later retry may work) |
216
+ | has no such mechanism — SMTP, most relays | ignores the key. It does not refuse the message, and does not emulate deduplication with state of its own: a cache in one process is not what the caller was promised |
217
+
218
+ Either way, **never put the key in the e-mail** — not in the body, not as a
219
+ header the recipient receives. It names the send, not the message, and it is
220
+ derived from what the e-mail is about (`order-42/receipt`): a caller did not
221
+ choose to show it. `@nxgt/mail-resend` sends it as Resend's `Idempotency-Key`;
222
+ `@nxgt/mail-smtp` ignores it.
223
+
224
+ Document which one yours does, and for how long the provider remembers a key:
225
+ past that window, a retry delivers again.
226
+
227
+ The conformance suite has no case for it — a transport that ignores the key
228
+ is as correct as one that honours it. Test it in your own specs: the key
229
+ reaches the provider where it should, and nowhere else.
230
+
231
+ ```ts
232
+ import { expect, test } from 'bun:test';
233
+ import { sampleMessage } from '@nxgt/mail/conformance';
234
+ import { createHttpMailer } from './http-mailer';
235
+
236
+ test('sends the idempotency key as a header, never in the body', async () => {
237
+ const requests: { headers: Headers; body: Record<string, unknown> }[] = [];
238
+ const mailer = createHttpMailer({
239
+ endpoint: 'https://mail.example.test/send',
240
+ apiKey: 'test',
241
+ fetch: async (_url, init) => {
242
+ requests.push({ headers: new Headers(init.headers), body: JSON.parse(String(init.body)) });
243
+ return Response.json({ id: 'm-1' });
244
+ },
245
+ });
246
+ await mailer.send({ ...sampleMessage, idempotencyKey: 'order-42/receipt' });
247
+ expect(requests[0]?.headers.get('idempotency-key')).toBe('order-42/receipt');
248
+ expect('idempotencyKey' in (requests[0]?.body ?? {})).toBe(false);
249
+ });
250
+ ```
251
+
193
252
  ## The conformance suite
194
253
 
195
254
  ```ts
package/docs/roadmap.md CHANGED
@@ -6,16 +6,13 @@ the only number.
6
6
 
7
7
  ## Now
8
8
 
9
- - **Attachments** — `attachments` on a `MailMessage`: each file's bytes as a
10
- `Uint8Array`, its name and its type. Bytes only — no path, no URL, no
11
- stream, so a transport never reads a file or fetches a URL for you; a large
12
- or sensitive file stays a signed link in the template. `checkMessage`
13
- refuses a name holding a path, a line break, a control or a format
14
- character, `.` or `..`, and a type that is not `type/subtype` or is a MIME
15
- container; the memory mailer keeps a copy of the
16
- bytes; the conformance suite gains `send.attachment` and
17
- `send.refusesAttachmentPath`, thirteen cases in all. The SMTP and Resend
18
- transports send them. Built, not yet published.
9
+ - **One-click unsubscribe** — `listUnsubscribe({ url, mailto? })` answers
10
+ RFC 8058's `List-Unsubscribe` and `List-Unsubscribe-Post` headers, to
11
+ spread into a message's `headers`, so Gmail and Yahoo offer their
12
+ one-click unsubscribe. A `url` that is not `https:`, or that would break
13
+ the header, and a `mailto` that is not a bare address are refused with
14
+ `MailRefused`, never quoting the value. No transport changes: the headers
15
+ travel as any other. Built, not yet published.
19
16
 
20
17
  ## Next
21
18
 
@@ -72,6 +69,25 @@ Nothing yet.
72
69
  The last ten, newest first, each with the version it came in. Everything
73
70
  before is in the [CHANGELOG](../CHANGELOG.md).
74
71
 
72
+ - **An idempotency key per send, v0.3.0** — `idempotencyKey` on a `MailMessage`
73
+ names the send, so sending it again — a retry after a timeout, a job run
74
+ twice — delivers it once where the transport can deduplicate; a transport
75
+ that cannot ignores it. `checkMessage` refuses a key that is not 1 to 256
76
+ visible ASCII characters, never quoting it. The memory mailer honours it as
77
+ Resend does: the same message under a key it already delivered answers
78
+ that delivery's `messageId` and delivers nothing more, a different message
79
+ under it is a `MailRefused`, a failed send leaves its key free, and
80
+ `clear()` forgets the keys.
81
+ - **Attachments, v0.2.0** — `attachments` on a `MailMessage`: each file's bytes as a
82
+ `Uint8Array`, its name and its type. Bytes only — no path, no URL, no
83
+ stream, so a transport never reads a file or fetches a URL for you; a large
84
+ or sensitive file stays a signed link in the template. `checkMessage`
85
+ refuses a name holding a path, a line break, a control or a format
86
+ character, `.` or `..`, and a type that is not `type/subtype` or is a MIME
87
+ container; the memory mailer keeps a copy of the
88
+ bytes; the conformance suite gains `send.attachment` and
89
+ `send.refusesAttachmentPath`, thirteen cases in all. The SMTP and Resend
90
+ transports send them.
75
91
  - **The run-time core, v0.1.0** — `@nxgt/mail`, with no dependency: the `Mailer` port
76
92
  a transport implements, the `Rendered` and `MailMessage` shapes it sends, and
77
93
  its two errors — `MailFailure` (`MAIL_FAILED`) when the transport could not
@@ -116,8 +132,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
116
132
  time, one output per locale and the manifest this renderer reads; e-mail
117
133
  components in the style of `@nxgt/material-vue`, with shared messages in
118
134
  `en` and `fr`; and nine ready e-mails built with your own brand.
119
- - **A starter that sends, with v0.1.0** — `examples/starter`'s `send.ts` renders its
120
- e-mails in `en` and `fr` through `createMailRenderer<MailEmails>` and
121
- hands them to `createMemoryMailer()`, run in CI:
122
- [`examples/starter`](https://github.com/softistx/nxgt-mail/tree/develop/examples/starter).
123
- In the repository; its README says how to start your own from npm.