@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.
- package/README.md +104 -4
- package/dist/chunks/{index-4h39j3n7.js → index-nkzwt8vn.js} +39 -2
- package/dist/chunks/index-nkzwt8vn.js.map +11 -0
- package/dist/chunks/index-we4n5yfz.js.map +2 -2
- package/dist/conformance/index.js +1 -1
- package/dist/errors.d.ts +2 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -2
- package/dist/index.js.map +4 -3
- package/dist/memory.d.ts +7 -1
- package/dist/memory.d.ts.map +1 -1
- package/dist/message.d.ts +2 -1
- package/dist/message.d.ts.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/unsubscribe.d.ts +43 -0
- package/dist/unsubscribe.d.ts.map +1 -0
- package/docs/README.md +3 -3
- package/docs/guide/sending.md +304 -9
- package/docs/guide/testing.md +91 -4
- package/docs/guide/transports.md +67 -8
- package/docs/roadmap.md +26 -15
- package/docs/troubleshooting.md +261 -3
- package/package.json +1 -1
- package/dist/chunks/index-4h39j3n7.js.map +0 -11
package/docs/guide/sending.md
CHANGED
|
@@ -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,
|
|
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 `
|
|
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
|
package/docs/guide/testing.md
CHANGED
|
@@ -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,
|
|
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
|
|
157
|
-
malformed
|
|
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).
|
package/docs/guide/transports.md
CHANGED
|
@@ -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. **
|
|
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
|
-
|
|
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.
|
|
70
|
+
"@nxgt/mail": "^0.3.0"
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
```
|
|
71
74
|
|
|
72
|
-
On `0.x`, a caret covers one minor: `^0.
|
|
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`
|
|
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: {
|
|
171
|
-
|
|
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
|
-
- **
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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.
|