@nxgt/mail 0.3.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 CHANGED
@@ -41,7 +41,7 @@ import without extensions, so `nodenext` is not supported.
41
41
 
42
42
  | Import | What it holds |
43
43
  | --- | --- |
44
- | `@nxgt/mail` | The port (`Mailer`, `MailMessage`, `Rendered`, `SentMail`, `Address`, `MailAttachment`), the errors (`MailError`, `MailFailure`, `MailRefused`), `createMemoryMailer`, `pickLocale` and `parseAcceptLanguage`, and what a transport calls first: `checkMessage`, `recipientsOf`, `addressOf`. No Node built-in: it runs anywhere |
44
+ | `@nxgt/mail` | The port (`Mailer`, `MailMessage`, `Rendered`, `SentMail`, `Address`, `MailAttachment`), the errors (`MailError`, `MailFailure`, `MailRefused`), `createMemoryMailer`, `pickLocale` and `parseAcceptLanguage`, `listUnsubscribe` with `ListUnsubscribeOptions` and `ListUnsubscribeHeaders`, and what a transport calls first: `checkMessage`, `recipientsOf`, `addressOf`. No Node built-in: it runs anywhere |
45
45
  | `@nxgt/mail/renderer` | The renderer: `createMailRenderer`, `MailRenderer`, `MailRendererOptions`, `RenderOptions`, `MailVariables`, and the types that type it with a build's `MailEmails` (`MailEmailsOf`, `AnyMailEmails`, `RenderArguments`). Reads the build with `node:fs` |
46
46
  | `@nxgt/mail/conformance` | **For transport authors**: `describeMailer`, its cases as data, `runMailerCase`, the messages they send (`sampleMessage`, `sampleAttachment`), and the memory mailer's harness as a worked example |
47
47
 
@@ -193,6 +193,45 @@ twice is delivered twice. A key that is not 1 to 256 visible ASCII characters
193
193
  is refused with `MailRefused`. See
194
194
  [Sending — idempotency](docs/guide/sending.md#idempotency--sending-once).
195
195
 
196
+ ### One-click unsubscribe — `listUnsubscribe`
197
+
198
+ Gmail and Yahoo require bulk senders to offer one-click unsubscribe on
199
+ marketing mail. `listUnsubscribe` answers its two headers, to spread into
200
+ `headers`, with a URL per recipient:
201
+
202
+ ```ts
203
+ import { listUnsubscribe, type Mailer, type Rendered } from '@nxgt/mail';
204
+
205
+ export async function sendNewsletter(
206
+ mailer: Mailer,
207
+ rendered: Rendered,
208
+ subscriber: { email: string; unsubscribeToken: string },
209
+ ): Promise<void> {
210
+ await mailer.send({
211
+ ...rendered,
212
+ to: subscriber.email,
213
+ headers: {
214
+ ...listUnsubscribe({
215
+ url: `https://example.com/unsubscribe?token=${encodeURIComponent(subscriber.unsubscribeToken)}`,
216
+ mailto: 'unsubscribe@example.com', // optional
217
+ }),
218
+ },
219
+ });
220
+ }
221
+ // List-Unsubscribe: <https://example.com/unsubscribe?token=…>, <mailto:unsubscribe@example.com>
222
+ // List-Unsubscribe-Post: List-Unsubscribe=One-Click
223
+ ```
224
+
225
+ The URL must start with `https://`, be printable ASCII, carry no user or
226
+ password, and hold no `<`, `>`, double quote or raw comma (percent-encode
227
+ it: `%2C`), and `mailto` must be a bare ASCII address; anything else is a
228
+ `MailRefused` that never quotes the URL — its token is a credential. Your
229
+ endpoint must unsubscribe on a `POST` with the body
230
+ `List-Unsubscribe=One-Click`, with no login and no confirmation. It belongs on
231
+ marketing and bulk mail, not on a password reset or a sign-in code. See
232
+ [Sending — one-click unsubscribe](docs/guide/sending.md#one-click-unsubscribe)
233
+ for the endpoint and DKIM.
234
+
196
235
  ### Errors — switch on `code`
197
236
 
198
237
  Both errors extend `MailError`, whose `code` is a union a `switch` exhausts.
@@ -396,7 +435,7 @@ gives a test file `describe` and `it` as bare identifiers, not on `globalThis`.
396
435
 
397
436
  ## Type safety, counted
398
437
 
399
- **22 plausible mistakes, 22 refused** at compile time, each measured by a
438
+ **23 plausible mistakes, 23 refused** at compile time, each measured by a
400
439
  `@ts-expect-error` in
401
440
  [`test/types/refusals.ts`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/test/types/refusals.ts)
402
441
  that fails the typecheck the moment it stops holding:
@@ -438,6 +477,11 @@ And the idempotency key:
438
477
  22. A number (`idempotencyKey: order.id`): the key is a string, as
439
478
  `order-42/receipt`.
440
479
 
480
+ And one-click unsubscribe:
481
+
482
+ 23. A `URL` object as `listUnsubscribe`'s `url`: the header holds text, so
483
+ pass `url.href`.
484
+
441
485
  The same file holds the calls that must keep compiling: a refusal that refuses
442
486
  the correct call is a bug.
443
487
 
@@ -450,6 +494,8 @@ the correct call is a bug.
450
494
  planned.
451
495
  - [Vocabulary](https://github.com/softistx/nxgt-mail/blob/develop/docs/vocabulary.md)
452
496
  — the words these pages use, defined once.
497
+ - [The starter](https://github.com/softistx/nxgt-mail/tree/develop/examples/starter)
498
+ — a Maizzle project that builds, renders and sends one e-mail, to copy.
453
499
 
454
500
  ## Licence
455
501
 
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/errors.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * What a transport refuses, as a string a caller can switch on.\n *\n * Every code is a **refusal at call time**. A refusal that can only come from\n * how the application was wired — a bad option passed to a factory — is a\n * bare `TypeError` instead: no handler should ever answer one.\n *\n * The codes are `SCREAMING_SNAKE` because they are data values, not API\n * identifiers. Every key in this package is `camelCase`.\n */\nexport type MailErrorCode =\n\t/**\n\t * The transport could not hand the message over: a refused connection, a\n\t * timeout, a 5xx from the provider, an expired credential. The transport's\n\t * own error is the `cause`.\n\t *\n\t * **Nothing is known to have been sent** — after a timeout or a dropped\n\t * connection, the provider may have taken it all the same. Retry later,\n\t * or tell the user it failed. Never report it as sent.\n\t */\n\t| 'MAIL_FAILED'\n\t/**\n\t * The message itself was refused, before or by the transport: no\n\t * recipient, something that is not an address, a line break in the\n\t * subject or a header, an attachment that is not bytes or is badly named,\n\t * a provider answering that the message is malformed or too large, or —\n\t * from `@nxgt/mail/renderer` — a URL variable that is not an `http:`,\n\t * `https:` or `mailto:` URL. Sending it again unchanged fails again.\n\t */\n\t| 'MAIL_REFUSED';\n\n/** Options every error of this package accepts. */\nexport interface MailErrorOptions {\n\t/** The error that caused this one, typically the transport's. */\n\treadonly cause?: unknown;\n}\n\n/**\n * The base class of every error this package throws at call time. It is\n * abstract: a transport throws {@link MailFailure} or {@link MailRefused}.\n *\n * **There is exactly one definition of this class.** A transport defines no\n * error class of its own and throws these, imported from its `@nxgt/mail`\n * peer, so `error instanceof MailFailure` holds whatever transport threw it.\n *\n * A message reports **a shape, never a value**: never a recipient address,\n * never a subject, never a link — the link in a verification e-mail is a\n * credential.\n */\nexport abstract class MailError extends Error {\n\toverride name = 'MailError';\n\t/**\n\t * Abstract, so a transport cannot throw a bare `MailError` that passes a\n\t * `code` check and fails `instanceof MailFailure`: it throws one of the two\n\t * subclasses.\n\t */\n\tabstract readonly code: MailErrorCode;\n\n\tconstructor(message: string, options?: MailErrorOptions) {\n\t\tsuper(message, { cause: options?.cause });\n\t}\n}\n\n/** The transport could not hand the message over. Code `MAIL_FAILED`. */\nexport class MailFailure extends MailError {\n\toverride name = 'MailFailure';\n\toverride readonly code = 'MAIL_FAILED' as const;\n}\n\n/** The message was refused as malformed. Code `MAIL_REFUSED`. */\nexport class MailRefused extends MailError {\n\toverride name = 'MailRefused';\n\toverride readonly code = 'MAIL_REFUSED' as const;\n}\n"
5
+ "/**\n * What a transport refuses, as a string a caller can switch on.\n *\n * Every code is a **refusal at call time**. A refusal that can only come from\n * how the application was wired — a bad option passed to a factory — is a\n * bare `TypeError` instead: no handler should ever answer one.\n *\n * The codes are `SCREAMING_SNAKE` because they are data values, not API\n * identifiers. Every key in this package is `camelCase`.\n */\nexport type MailErrorCode =\n\t/**\n\t * The transport could not hand the message over: a refused connection, a\n\t * timeout, a 5xx from the provider, an expired credential. The transport's\n\t * own error is the `cause`.\n\t *\n\t * **Nothing is known to have been sent** — after a timeout or a dropped\n\t * connection, the provider may have taken it all the same. Retry later,\n\t * or tell the user it failed. Never report it as sent.\n\t */\n\t| 'MAIL_FAILED'\n\t/**\n\t * The message itself was refused, before or by the transport: no\n\t * recipient, something that is not an address, a line break in the\n\t * subject or a header, an attachment that is not bytes or is badly named,\n\t * a provider answering that the message is malformed or too large, or —\n\t * from `@nxgt/mail/renderer` — a URL variable that is not an `http:`,\n\t * `https:` or `mailto:` URL, or, from `listUnsubscribe`, an unsubscribe URL\n\t * or address it will not write. Sending it again unchanged fails again.\n\t */\n\t| 'MAIL_REFUSED';\n\n/** Options every error of this package accepts. */\nexport interface MailErrorOptions {\n\t/** The error that caused this one, typically the transport's. */\n\treadonly cause?: unknown;\n}\n\n/**\n * The base class of every error this package throws at call time. It is\n * abstract: a transport throws {@link MailFailure} or {@link MailRefused}.\n *\n * **There is exactly one definition of this class.** A transport defines no\n * error class of its own and throws these, imported from its `@nxgt/mail`\n * peer, so `error instanceof MailFailure` holds whatever transport threw it.\n *\n * A message reports **a shape, never a value**: never a recipient address,\n * never a subject, never a link — the link in a verification e-mail is a\n * credential.\n */\nexport abstract class MailError extends Error {\n\toverride name = 'MailError';\n\t/**\n\t * Abstract, so a transport cannot throw a bare `MailError` that passes a\n\t * `code` check and fails `instanceof MailFailure`: it throws one of the two\n\t * subclasses.\n\t */\n\tabstract readonly code: MailErrorCode;\n\n\tconstructor(message: string, options?: MailErrorOptions) {\n\t\tsuper(message, { cause: options?.cause });\n\t}\n}\n\n/** The transport could not hand the message over. Code `MAIL_FAILED`. */\nexport class MailFailure extends MailError {\n\toverride name = 'MailFailure';\n\toverride readonly code = 'MAIL_FAILED' as const;\n}\n\n/** The message was refused as malformed. Code `MAIL_REFUSED`. */\nexport class MailRefused extends MailError {\n\toverride name = 'MailRefused';\n\toverride readonly code = 'MAIL_REFUSED' as const;\n}\n"
6
6
  ],
7
- "mappings": ";AAiDO,MAAe,mBAAkB,MAAM;AAAA,EAS7C,WAAW,CAAC,SAAiB,SAA4B;AAAA,IACxD,MAAM,SAAS,EAAE,OAAO,SAAS,MAAM,CAAC;AAAA,IAThC,YAAO;AAAA;AAWjB;AAAA;AAGO,MAAM,qBAAoB,WAAU;AAAA;AAAA;AAAA,IACjC,YAAO;AAAA,IACE,YAAO;AAAA;AAC1B;AAAA;AAGO,MAAM,qBAAoB,WAAU;AAAA;AAAA;AAAA,IACjC,YAAO;AAAA,IACE,YAAO;AAAA;AAC1B;",
7
+ "mappings": ";AAkDO,MAAe,mBAAkB,MAAM;AAAA,EAS7C,WAAW,CAAC,SAAiB,SAA4B;AAAA,IACxD,MAAM,SAAS,EAAE,OAAO,SAAS,MAAM,CAAC;AAAA,IAThC,YAAO;AAAA;AAWjB;AAAA;AAGO,MAAM,qBAAoB,WAAU;AAAA;AAAA;AAAA,IACjC,YAAO;AAAA,IACE,YAAO;AAAA;AAC1B;AAAA;AAGO,MAAM,qBAAoB,WAAU;AAAA;AAAA;AAAA,IACjC,YAAO;AAAA,IACE,YAAO;AAAA;AAC1B;",
8
8
  "debugId": "5753AA1D988C668964756E2164756E21",
9
9
  "names": []
10
10
  }
package/dist/errors.d.ts CHANGED
@@ -25,7 +25,8 @@ export type MailErrorCode =
25
25
  * subject or a header, an attachment that is not bytes or is badly named,
26
26
  * a provider answering that the message is malformed or too large, or —
27
27
  * from `@nxgt/mail/renderer` — a URL variable that is not an `http:`,
28
- * `https:` or `mailto:` URL. Sending it again unchanged fails again.
28
+ * `https:` or `mailto:` URL, or, from `listUnsubscribe`, an unsubscribe URL
29
+ * or address it will not write. Sending it again unchanged fails again.
29
30
  */
30
31
  | 'MAIL_REFUSED';
31
32
  /** Options every error of this package accepts. */
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,MAAM,aAAa;AACxB;;;;;;;;GAQG;AACD,aAAa;AACf;;;;;;;GAOG;GACD,cAAc,CAAC;AAElB,mDAAmD;AACnD,MAAM,WAAW,gBAAgB;IAChC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,8BAAsB,SAAU,SAAQ,KAAK;IACnC,IAAI,SAAe;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;gBAE1B,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB;CAGvD;AAED,yEAAyE;AACzE,qBAAa,WAAY,SAAQ,SAAS;IAChC,IAAI,SAAiB;IAC9B,SAAkB,IAAI,EAAG,aAAa,CAAU;CAChD;AAED,iEAAiE;AACjE,qBAAa,WAAY,SAAQ,SAAS;IAChC,IAAI,SAAiB;IAC9B,SAAkB,IAAI,EAAG,cAAc,CAAU;CACjD"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,MAAM,aAAa;AACxB;;;;;;;;GAQG;AACD,aAAa;AACf;;;;;;;;GAQG;GACD,cAAc,CAAC;AAElB,mDAAmD;AACnD,MAAM,WAAW,gBAAgB;IAChC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,8BAAsB,SAAU,SAAQ,KAAK;IACnC,IAAI,SAAe;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;gBAE1B,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB;CAGvD;AAED,yEAAyE;AACzE,qBAAa,WAAY,SAAQ,SAAS;IAChC,IAAI,SAAiB;IAC9B,SAAkB,IAAI,EAAG,aAAa,CAAU;CAChD;AAED,iEAAiE;AACjE,qBAAa,WAAY,SAAQ,SAAS;IAChC,IAAI,SAAiB;IAC9B,SAAkB,IAAI,EAAG,cAAc,CAAU;CACjD"}
package/dist/index.d.ts CHANGED
@@ -18,4 +18,5 @@ export { parseAcceptLanguage, pickLocale, type WantedLocales } from './locale';
18
18
  export { createMemoryMailer, type MemoryMail, type MemoryMailer, } from './memory';
19
19
  export { addressOf, checkMessage, recipientsOf } from './message';
20
20
  export type { Address, MailAttachment, Mailer, MailMessage, Rendered, SentMail, } from './types';
21
+ export { type ListUnsubscribeHeaders, type ListUnsubscribeOptions, listUnsubscribe, } from './unsubscribe';
21
22
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EACN,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,WAAW,EACX,WAAW,GACX,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,mBAAmB,EAAE,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAC/E,OAAO,EACN,kBAAkB,EAClB,KAAK,UAAU,EACf,KAAK,YAAY,GACjB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAClE,YAAY,EACX,OAAO,EACP,cAAc,EACd,MAAM,EACN,WAAW,EACX,QAAQ,EACR,QAAQ,GACR,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EACN,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,WAAW,EACX,WAAW,GACX,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,mBAAmB,EAAE,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAC/E,OAAO,EACN,kBAAkB,EAClB,KAAK,UAAU,EACf,KAAK,YAAY,GACjB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAClE,YAAY,EACX,OAAO,EACP,cAAc,EACd,MAAM,EACN,WAAW,EACX,QAAQ,EACR,QAAQ,GACR,MAAM,SAAS,CAAC;AACjB,OAAO,EACN,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAC3B,eAAe,GACf,MAAM,eAAe,CAAC"}
package/dist/index.js CHANGED
@@ -13,6 +13,32 @@ import {
13
13
  MailFailure2,
14
14
  MailRefused2
15
15
  } from "./chunks/index-we4n5yfz.js";
16
+ // src/unsubscribe.ts
17
+ var URL_ALLOWED = /^https:\/\/[\x21-\x7E]+$/;
18
+ var URL_REFUSED = /[<>,"`\\{}|^]/;
19
+ var MAILTO = /^[A-Za-z0-9._~!$'*+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
20
+ function listUnsubscribe(options) {
21
+ if (typeof options !== "object" || options === null) {
22
+ throw new TypeError("listUnsubscribe: options must be an object, as { url }");
23
+ }
24
+ if (typeof options.url !== "string") {
25
+ throw new TypeError("listUnsubscribe: url must be a string");
26
+ }
27
+ if (options.mailto !== undefined && typeof options.mailto !== "string") {
28
+ throw new TypeError("listUnsubscribe: mailto must be a string");
29
+ }
30
+ if (!URL_ALLOWED.test(options.url) || URL_REFUSED.test(options.url) || !URL.canParse(options.url) || new URL(options.url).username !== "" || new URL(options.url).password !== "") {
31
+ throw new MailRefused2("listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma");
32
+ }
33
+ if (options.mailto !== undefined && !MAILTO.test(options.mailto)) {
34
+ throw new MailRefused2("listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com");
35
+ }
36
+ const mailto = options.mailto === undefined ? "" : `, <mailto:${options.mailto}>`;
37
+ return {
38
+ "List-Unsubscribe": `<${options.url}>${mailto}`,
39
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
40
+ };
41
+ }
16
42
  export {
17
43
  MailError2 as MailError,
18
44
  MailFailure2 as MailFailure,
@@ -20,10 +46,11 @@ export {
20
46
  addressOf2 as addressOf,
21
47
  checkMessage2 as checkMessage,
22
48
  createMemoryMailer2 as createMemoryMailer,
49
+ listUnsubscribe,
23
50
  parseAcceptLanguage2 as parseAcceptLanguage,
24
51
  pickLocale2 as pickLocale,
25
52
  recipientsOf2 as recipientsOf
26
53
  };
27
54
 
28
- //# debugId=F6BB19285305093D64756E2164756E21
55
+ //# debugId=AB5848B04CC61C7064756E2164756E21
29
56
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": [],
3
+ "sources": ["../src/unsubscribe.ts"],
4
4
  "sourcesContent": [
5
+ "import { MailRefused } from './errors';\n\n/** Where a recipient unsubscribes: the one-click URL, and an address as well. */\nexport interface ListUnsubscribeOptions {\n\t/**\n\t * The `https:` URL a mail client POSTs `List-Unsubscribe=One-Click` to —\n\t * one per recipient, carrying what identifies them, as\n\t * `https://example.com/unsubscribe?token=…`. It unsubscribes on that POST\n\t * alone: no login, no confirmation page, no redirect.\n\t */\n\treadonly url: string;\n\t/** An address that unsubscribes whoever writes to it, for clients that only send mail. */\n\treadonly mailto?: string;\n}\n\n/**\n * The two headers of RFC 8058's one-click unsubscribe. A type, not an\n * interface: an interface has no index signature, and would not go into\n * `headers` as it is.\n */\nexport type ListUnsubscribeHeaders = {\n\treadonly 'List-Unsubscribe': string;\n\treadonly 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click';\n};\n\n// RFC 2369 wants an RFC 3986 URI inside `<…>`: printable ASCII only — a\n// transport would encode a header holding anything else, and no client\n// would find the URL in it — and none of what ends the URL (`<`, `>`), what\n// RFC 2369 reads as the next one (`,`), or what no URI holds as is (a double\n// quote, a backtick, a backslash, braces, `|`, `^`). Percent-encode it.\nconst URL_ALLOWED = /^https:\\/\\/[\\x21-\\x7E]+$/;\nconst URL_REFUSED = /[<>,\"`\\\\{}|^]/;\n// RFC 6068 reads `?`, `&`, `=`, `#` and `%` inside a mailto: as structure — a\n// subject, a second recipient — so the address is plain ASCII without them.\nconst MAILTO = /^[A-Za-z0-9._~!$'*+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)+$/;\n\n/**\n * The headers that give an e-mail Gmail's and Yahoo's one-click unsubscribe\n * (RFC 8058, with RFC 2369's `List-Unsubscribe`), to spread into a message's\n * `headers`:\n *\n * ```ts\n * await mailer.send({\n * ...rendered,\n * to: user.email,\n * headers: listUnsubscribe({ url: `https://example.com/unsubscribe?token=${token}` }),\n * });\n * ```\n *\n * Refuses, with a {@link MailRefused} that never quotes the value, a `url`\n * that is not `https://` — RFC 8058 requires it — or is not printable ASCII,\n * carries a user or a password, or holds `<`, `>`, a double quote or a raw `,`; and\n * a `mailto` that is not a bare ASCII address. The URL is\n * often built from a token, and a token is a credential: the message names\n * the rule, not the link.\n */\nexport function listUnsubscribe(\n\toptions: ListUnsubscribeOptions,\n): ListUnsubscribeHeaders {\n\tif (typeof options !== 'object' || options === null) {\n\t\tthrow new TypeError(\n\t\t\t'listUnsubscribe: options must be an object, as { url }',\n\t\t);\n\t}\n\tif (typeof options.url !== 'string') {\n\t\tthrow new TypeError('listUnsubscribe: url must be a string');\n\t}\n\tif (options.mailto !== undefined && typeof options.mailto !== 'string') {\n\t\tthrow new TypeError('listUnsubscribe: mailto must be a string');\n\t}\n\tif (\n\t\t!URL_ALLOWED.test(options.url) ||\n\t\tURL_REFUSED.test(options.url) ||\n\t\t!URL.canParse(options.url) ||\n\t\t// A user and a password in a header every relay and recipient reads.\n\t\tnew URL(options.url).username !== '' ||\n\t\tnew URL(options.url).password !== ''\n\t) {\n\t\tthrow new MailRefused(\n\t\t\t'listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma',\n\t\t);\n\t}\n\tif (options.mailto !== undefined && !MAILTO.test(options.mailto)) {\n\t\tthrow new MailRefused(\n\t\t\t'listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com',\n\t\t);\n\t}\n\tconst mailto =\n\t\toptions.mailto === undefined ? '' : `, <mailto:${options.mailto}>`;\n\treturn {\n\t\t'List-Unsubscribe': `<${options.url}>${mailto}`,\n\t\t'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n\t};\n}\n"
5
6
  ],
6
- "mappings": "",
7
- "debugId": "F6BB19285305093D64756E2164756E21",
7
+ "mappings": ";;;;;;;;;;;;;;;;AA8BA,IAAM,cAAc;AACpB,IAAM,cAAc;AAGpB,IAAM,SAAS;AAsBR,SAAS,eAAe,CAC9B,SACyB;AAAA,EACzB,IAAI,OAAO,YAAY,YAAY,YAAY,MAAM;AAAA,IACpD,MAAM,IAAI,UACT,wDACD;AAAA,EACD;AAAA,EACA,IAAI,OAAO,QAAQ,QAAQ,UAAU;AAAA,IACpC,MAAM,IAAI,UAAU,uCAAuC;AAAA,EAC5D;AAAA,EACA,IAAI,QAAQ,WAAW,aAAa,OAAO,QAAQ,WAAW,UAAU;AAAA,IACvE,MAAM,IAAI,UAAU,0CAA0C;AAAA,EAC/D;AAAA,EACA,IACC,CAAC,YAAY,KAAK,QAAQ,GAAG,KAC7B,YAAY,KAAK,QAAQ,GAAG,KAC5B,CAAC,IAAI,SAAS,QAAQ,GAAG,KAEzB,IAAI,IAAI,QAAQ,GAAG,EAAE,aAAa,MAClC,IAAI,IAAI,QAAQ,GAAG,EAAE,aAAa,IACjC;AAAA,IACD,MAAM,IAAI,aACT,mHACD;AAAA,EACD;AAAA,EACA,IAAI,QAAQ,WAAW,aAAa,CAAC,OAAO,KAAK,QAAQ,MAAM,GAAG;AAAA,IACjE,MAAM,IAAI,aACT,mFACD;AAAA,EACD;AAAA,EACA,MAAM,SACL,QAAQ,WAAW,YAAY,KAAK,aAAa,QAAQ;AAAA,EAC1D,OAAO;AAAA,IACN,oBAAoB,IAAI,QAAQ,OAAO;AAAA,IACvC,yBAAyB;AAAA,EAC1B;AAAA;",
8
+ "debugId": "AB5848B04CC61C7064756E2164756E21",
8
9
  "names": []
9
10
  }
@@ -0,0 +1,43 @@
1
+ /** Where a recipient unsubscribes: the one-click URL, and an address as well. */
2
+ export interface ListUnsubscribeOptions {
3
+ /**
4
+ * The `https:` URL a mail client POSTs `List-Unsubscribe=One-Click` to —
5
+ * one per recipient, carrying what identifies them, as
6
+ * `https://example.com/unsubscribe?token=…`. It unsubscribes on that POST
7
+ * alone: no login, no confirmation page, no redirect.
8
+ */
9
+ readonly url: string;
10
+ /** An address that unsubscribes whoever writes to it, for clients that only send mail. */
11
+ readonly mailto?: string;
12
+ }
13
+ /**
14
+ * The two headers of RFC 8058's one-click unsubscribe. A type, not an
15
+ * interface: an interface has no index signature, and would not go into
16
+ * `headers` as it is.
17
+ */
18
+ export type ListUnsubscribeHeaders = {
19
+ readonly 'List-Unsubscribe': string;
20
+ readonly 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click';
21
+ };
22
+ /**
23
+ * The headers that give an e-mail Gmail's and Yahoo's one-click unsubscribe
24
+ * (RFC 8058, with RFC 2369's `List-Unsubscribe`), to spread into a message's
25
+ * `headers`:
26
+ *
27
+ * ```ts
28
+ * await mailer.send({
29
+ * ...rendered,
30
+ * to: user.email,
31
+ * headers: listUnsubscribe({ url: `https://example.com/unsubscribe?token=${token}` }),
32
+ * });
33
+ * ```
34
+ *
35
+ * Refuses, with a {@link MailRefused} that never quotes the value, a `url`
36
+ * that is not `https://` — RFC 8058 requires it — or is not printable ASCII,
37
+ * carries a user or a password, or holds `<`, `>`, a double quote or a raw `,`; and
38
+ * a `mailto` that is not a bare ASCII address. The URL is
39
+ * often built from a token, and a token is a credential: the message names
40
+ * the rule, not the link.
41
+ */
42
+ export declare function listUnsubscribe(options: ListUnsubscribeOptions): ListUnsubscribeHeaders;
43
+ //# sourceMappingURL=unsubscribe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unsubscribe.d.ts","sourceRoot":"","sources":["../src/unsubscribe.ts"],"names":[],"mappings":"AAEA,iFAAiF;AACjF,MAAM,WAAW,sBAAsB;IACtC;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,0FAA0F;IAC1F,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACpC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,uBAAuB,EAAE,4BAA4B,CAAC;CAC/D,CAAC;AAaF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAC9B,OAAO,EAAE,sBAAsB,GAC7B,sBAAsB,CAmCxB"}
package/docs/README.md CHANGED
@@ -8,7 +8,7 @@ mailer, transport, hand-over, refusal, failure — are defined once, in the
8
8
  | Page | Read it when |
9
9
  | --- | --- |
10
10
  | [Rendering](guide/rendering.md) | You are turning a Maizzle build of `@nxgt/mail-i18n` into an e-mail with `createMailRenderer`: its options, typing it with the build's `MailEmails`, choosing the locale, escaping, URL variables, deploying the build, and every error |
11
- | [Sending](guide/sending.md) | You are calling `mailer.send`: the `MailMessage` shape, addresses, headers, attachments, the idempotency key, what `send` answers, and turning `MailFailure` and `MailRefused` into a response |
11
+ | [Sending](guide/sending.md) | You are calling `mailer.send`: the `MailMessage` shape, addresses, headers, one-click unsubscribe with `listUnsubscribe` (the headers, DKIM, the endpoint), attachments, the idempotency key, what `send` answers, and turning `MailFailure` and `MailRefused` into a response |
12
12
  | [Testing](guide/testing.md) | You are testing code that sends e-mail with `createMemoryMailer`: reading the outbox and its attachments, making a send fail, counting attempts, a retry under an idempotency key |
13
13
  | [Locales](guide/locales.md) | You are choosing the locale an e-mail is rendered in, with `pickLocale` and `parseAcceptLanguage` |
14
14
  | [Writing a transport](guide/transports.md) | You are implementing the `Mailer` port for a provider — the idempotency key included — and running `@nxgt/mail/conformance` against it |
@@ -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, the idempotency key that makes
5
- a retry safe, what it answers, and what it 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';
@@ -93,7 +93,7 @@ interface MailAttachment {
93
93
  | `to` | `Address \| readonly Address[]` | yes | One recipient or several, at least one |
94
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 |
95
95
  | `replyTo` | `Address` | no | Where replies go |
96
- | `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) |
97
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
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 |
99
99
 
@@ -211,13 +211,14 @@ declare const rendered: Rendered;
211
211
  const message: MailMessage = {
212
212
  ...rendered,
213
213
  to: 'ada@example.com',
214
- headers: {
215
- 'List-Unsubscribe': '<https://example.com/unsubscribe?u=42>',
216
- 'X-Entity-Ref-ID': 'welcome-42',
217
- },
214
+ headers: { 'X-Entity-Ref-ID': 'welcome-42' },
218
215
  };
219
216
  ```
220
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
+
221
222
  | Written | Answer |
222
223
  | --- | --- |
223
224
  | `{ 'X Bad': 'v' }` | `MailRefused`: `send: a header name must be letters, digits and hyphens` |
@@ -225,6 +226,213 @@ const message: MailMessage = {
225
226
  | `{ Bcc: 'eve@example.com' }` | `MailRefused`: `send: header Bcc is reserved — addresses, the subject and the MIME structure are never custom headers` |
226
227
  | `{ 'content-type': 'text/plain' }` | `MailRefused`: `send: header content-type is reserved — …` |
227
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
+
228
436
  ## Attachments
229
437
 
230
438
  An attachment is a file's **bytes**, its name and its type:
@@ -413,7 +621,7 @@ class MailRefused extends MailError {
413
621
  | Code | Class | When | Sending it again |
414
622
  | --- | --- | --- | --- |
415
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 |
416
- | `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, 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 |
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 |
417
625
 
418
626
  `MailError` is **abstract**: catch it, test `instanceof MailError`, but
419
627
  `new MailError(…)` does not compile — a bare one would pass a `code` check and
package/docs/roadmap.md CHANGED
@@ -6,15 +6,13 @@ the only number.
6
6
 
7
7
  ## Now
8
8
 
9
- - **An idempotency key per send** — `idempotencyKey` on a `MailMessage`
10
- names the send, so sending it again — a retry after a timeout, a job run
11
- twice — delivers it once where the transport can deduplicate; a transport
12
- that cannot ignores it. `checkMessage` refuses a key that is not 1 to 256
13
- visible ASCII characters, never quoting it. The memory mailer honours it as
14
- Resend does: the same message under a key it already delivered answers
15
- that delivery's `messageId` and delivers nothing more, a different message
16
- under it is a `MailRefused`, a failed send leaves its key free, and
17
- `clear()` forgets the keys. 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.
18
16
 
19
17
  ## Next
20
18
 
@@ -71,6 +69,15 @@ Nothing yet.
71
69
  The last ten, newest first, each with the version it came in. Everything
72
70
  before is in the [CHANGELOG](../CHANGELOG.md).
73
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.
74
81
  - **Attachments, v0.2.0** — `attachments` on a `MailMessage`: each file's bytes as a
75
82
  `Uint8Array`, its name and its type. Bytes only — no path, no URL, no
76
83
  stream, so a transport never reads a file or fetches a URL for you; a large
@@ -125,8 +132,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
125
132
  time, one output per locale and the manifest this renderer reads; e-mail
126
133
  components in the style of `@nxgt/material-vue`, with shared messages in
127
134
  `en` and `fr`; and nine ready e-mails built with your own brand.
128
- - **A starter that sends, with v0.1.0** — `examples/starter`'s `send.ts` renders its
129
- e-mails in `en` and `fr` through `createMailRenderer<MailEmails>` and
130
- hands them to `createMemoryMailer()`, run in CI:
131
- [`examples/starter`](https://github.com/softistx/nxgt-mail/tree/develop/examples/starter).
132
- In the repository; its README says how to start your own from npm.
@@ -10,7 +10,8 @@ How the messages are shaped:
10
10
  verification e-mail is a credential, and it never reaches a log through an
11
11
  error.
12
12
  - **Every message starts with the call you wrote**: `send: …`,
13
- `createMailRenderer: …`, `render: …`, `pickLocale: …`, `describeMailer: …`.
13
+ `listUnsubscribe: …`, `createMailRenderer: …`, `render: …`,
14
+ `pickLocale: …`, `describeMailer: …`.
14
15
  A conformance case that fails starts with `conformance: …`.
15
16
  - **A `TypeError` is a wiring mistake**: it comes from how the application
16
17
  was put together — or, from `render`, from how the call was written —
@@ -69,6 +70,14 @@ How the messages are shaped:
69
70
  - [An e-mail is delivered twice although it has an `idempotencyKey`](#an-e-mail-is-delivered-twice-although-it-has-an-idempotencykey)
70
71
  - [`send: the memory mailer was told to fail this send`](#send-the-memory-mailer-was-told-to-fail-this-send)
71
72
 
73
+ **Unsubscribe**
74
+ - [`listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma`](#listunsubscribe-url-must-be-an-https-url-in-printable-ascii-without-credentials---quotes-or-a-raw-comma)
75
+ - [`listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com`](#listunsubscribe-mailto-must-be-a-bare-e-mail-address-as-unsubscribeexamplecom)
76
+ - [`listUnsubscribe: options must be an object, as { url }`](#listunsubscribe-options-must-be-an-object-as--url-)
77
+ - [`listUnsubscribe: url must be a string`](#listunsubscribe-url-must-be-a-string)
78
+ - [`listUnsubscribe: mailto must be a string`](#listunsubscribe-mailto-must-be-a-string)
79
+ - [Gmail shows no unsubscribe button](#gmail-shows-no-unsubscribe-button)
80
+
72
81
  **Locale**
73
82
  - [`pickLocale: supported must hold at least one locale`](#picklocale-supported-must-hold-at-least-one-locale)
74
83
  - [`pickLocale: fallback must be one of supported`](#picklocale-fallback-must-be-one-of-supported)
@@ -869,6 +878,164 @@ beforeEach(() => mailer.clear());
869
878
 
870
879
  ---
871
880
 
881
+ ## Unsubscribe
882
+
883
+ `listUnsubscribe` checks its options **when it is called**, before any
884
+ `send`. A value that is text but not a usable URL or address is a
885
+ `MailRefused` (`code: 'MAIL_REFUSED'`), and it never quotes the value: the
886
+ URL usually carries a per-recipient token, and a token is a credential. A
887
+ value that is not text at all is a `TypeError`, a mistake in the code.
888
+
889
+ ### `listUnsubscribe: url must be an https:// URL in printable ASCII, without credentials, <, >, quotes or a raw comma`
890
+
891
+ A `MailRefused`, `code: 'MAIL_REFUSED'`.
892
+
893
+ **When:** `listUnsubscribe({ url })`, with a `url` that does not start with
894
+ `https://` (`http:`, `mailto:`, relative, empty, `HTTPS://` in capitals), that
895
+ carries a user or a password (`https://user:pass@…`), or that holds a space,
896
+ a line break, a character outside ASCII, `<`, `>`, a double quote, a backtick, a backslash, a
897
+ brace, `|`, `^` or a `,` as it is: typically a token or a list name pasted
898
+ into a template string without being encoded, or an `http:` URL from a
899
+ development configuration.
900
+ **Why:** RFC 8058 accepts only an `https:` URL for one-click unsubscribe, and
901
+ RFC 2369 an RFC 3986 URI — printable ASCII — between `<` and `>`. A
902
+ transport encodes a header holding anything else, and no client finds the URL
903
+ in it; a `>` would end the URL, and a raw comma is RFC 2369's separator
904
+ between two URLs, so a mail client would read the rest as a second one. A
905
+ user and a password would be read by every relay and recipient.
906
+ **Fix:** build the URL with `new URL()` and set each value with
907
+ `searchParams.set`, which percent-encodes it (`,` becomes `%2C`, a space
908
+ `+`), then pass `.href`:
909
+
910
+ ```ts
911
+ import { listUnsubscribe } from '@nxgt/mail';
912
+
913
+ declare const token: string;
914
+
915
+ const url = new URL('https://example.com/unsubscribe');
916
+ url.searchParams.set('token', token);
917
+
918
+ const headers = listUnsubscribe({ url: url.href });
919
+ ```
920
+
921
+ A value in the path is encoded with `encodeURIComponent` (`/u/${encodeURIComponent(list)}`).
922
+ In development, use an `https:` URL as well, or leave the headers out.
923
+
924
+ ### `listUnsubscribe: mailto must be a bare e-mail address, as unsubscribe@example.com`
925
+
926
+ A `MailRefused`, `code: 'MAIL_REFUSED'`.
927
+
928
+ **When:** `listUnsubscribe({ url, mailto })`, with a `mailto` that has a
929
+ display name (`Unsubscribe <unsubscribe@example.com>`), a `mailto:` prefix,
930
+ two addresses, no `@`, a domain without a dot, a character outside ASCII,
931
+ or `?`, `&`, `=`, `#`, `%` or a double quote (`u@example.com?subject=stop`).
932
+ **Why:** `mailto` is one mailbox, and `listUnsubscribe` writes the
933
+ `<mailto:…>` around it itself; a name, a prefix or a second address would
934
+ break the header or be read as something else — in a `mailto:`, `?` starts
935
+ header fields, and `?cc=` would add a recipient.
936
+ **Fix:** pass the address alone:
937
+
938
+ ```ts
939
+ import { listUnsubscribe } from '@nxgt/mail';
940
+
941
+ declare const token: string;
942
+
943
+ const headers = listUnsubscribe({
944
+ url: `https://example.com/unsubscribe?token=${encodeURIComponent(token)}`,
945
+ mailto: 'unsubscribe@example.com', // ✗ 'mailto:unsubscribe@example.com'
946
+ });
947
+ ```
948
+
949
+ ### `listUnsubscribe: options must be an object, as { url }`
950
+
951
+ A `TypeError`.
952
+
953
+ **When:** `listUnsubscribe()` with no argument, or with the URL alone:
954
+ typically `listUnsubscribe(url)`.
955
+ **Why:** the options are one object, and `url` is required in it.
956
+ **Fix:** `listUnsubscribe({ url })`.
957
+
958
+ ### `listUnsubscribe: url must be a string`
959
+
960
+ A `TypeError`.
961
+
962
+ **When:** `listUnsubscribe({ url })`, with a `url` that is a `URL` object,
963
+ `undefined` or anything else that is not text — from untyped code, since
964
+ TypeScript already refuses a `URL` object (`Type 'URL' is not assignable to
965
+ type 'string'`).
966
+ **Why:** the header holds text; the helper does not guess how to turn a
967
+ value into a URL.
968
+ **Fix:** pass the URL's text:
969
+
970
+ ```ts
971
+ import { listUnsubscribe } from '@nxgt/mail';
972
+
973
+ const url = new URL('https://example.com/unsubscribe');
974
+
975
+ const headers = listUnsubscribe({ url: url.href }); // ✗ { url }
976
+ ```
977
+
978
+ ### `listUnsubscribe: mailto must be a string`
979
+
980
+ A `TypeError`.
981
+
982
+ **When:** `listUnsubscribe({ url, mailto })`, with a `mailto` that is set
983
+ but is not a string — `null`, or an `{ name, address }` object.
984
+ **Why:** `mailto` is one bare address, as text; leave it out for none.
985
+ **Fix:** `mailto: 'unsubscribe@example.com'`, or no `mailto` at all.
986
+
987
+ ### Gmail shows no unsubscribe button
988
+
989
+ **When:** the message carries both headers from `listUnsubscribe`, yet
990
+ Gmail (or Yahoo) shows no "Unsubscribe" link next to the sender.
991
+ **Why:** the headers make the button possible; the mailbox provider decides
992
+ whether to show it. The usual causes:
993
+
994
+ - **The sender is not a bulk sender, or its reputation is low.** Gmail shows
995
+ the button to senders it recognises as sending bulk mail with a good
996
+ reputation; a new domain, or a handful of test messages, may never get it.
997
+ - **The message is not DKIM-signed by the sending domain**, or the signature
998
+ does not cover the two headers. RFC 8058 requires a valid DKIM signature
999
+ whose `h=` includes `List-Unsubscribe` and `List-Unsubscribe-Post`, and
1000
+ Gmail and Yahoo also want it aligned with the `From` domain. Check the received message's original: the
1001
+ `DKIM-Signature` must have `d=` your domain and name both headers.
1002
+ - **The endpoint does not unsubscribe on the POST alone.** The client POSTs
1003
+ `List-Unsubscribe=One-Click` to the URL, with no cookie and no session. An
1004
+ answer that is a redirect, a login page, a confirmation page, or an error
1005
+ for a `POST` (a route that only answers `GET`) is a failed unsubscribe.
1006
+ - **The e-mail is transactional** — a sign-in code, a password reset, a
1007
+ receipt. Gmail does not offer to unsubscribe from those, and they should
1008
+ not carry the headers: nobody unsubscribes from their own password reset.
1009
+
1010
+ **Fix:** set the headers only on e-mails a recipient subscribed to, send
1011
+ from a domain that signs with DKIM, and make the URL unsubscribe on the
1012
+ `POST` itself:
1013
+
1014
+ ```ts
1015
+ declare function unsubscribeByToken(token: string): Promise<void>; // yours
1016
+
1017
+ // POST https://example.com/unsubscribe?token=…, body List-Unsubscribe=One-Click
1018
+ async function unsubscribe(request: Request): Promise<Response> {
1019
+ const token = new URL(request.url).searchParams.get('token') ?? '';
1020
+ if (request.method === 'POST') {
1021
+ const form = await request.formData();
1022
+ if (form.get('List-Unsubscribe') !== 'One-Click') return new Response(null, { status: 400 });
1023
+ await unsubscribeByToken(token); // no login, no confirmation
1024
+ return new Response(null, { status: 200 }); // never a redirect
1025
+ }
1026
+ // GET: a person followed the link. Show a page that posts the same form.
1027
+ return new Response(
1028
+ '<form method="post"><input type="hidden" name="List-Unsubscribe" value="One-Click"><button>Unsubscribe</button></form>',
1029
+ { headers: { 'content-type': 'text/html; charset=utf-8' } },
1030
+ );
1031
+ }
1032
+ ```
1033
+
1034
+ The full handler, with what the `GET` page may show, is in
1035
+ [the sending guide](guide/sending.md#one-click-unsubscribe).
1036
+
1037
+ ---
1038
+
872
1039
  ## Locale
873
1040
 
874
1041
  ### `pickLocale: supported must hold at least one locale`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/mail",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The run-time side of transactional e-mail: the renderer that fills a Maizzle build made with @nxgt/mail-i18n, the Mailer a transport implements, its errors, a memory transport and locale selection. No dependency.",
5
5
  "license": "MIT",
6
6
  "type": "module",