@sdxc/mail 2026.9.15 → 2026.9.29

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
@@ -25,7 +25,8 @@ alongside this package.
25
25
 
26
26
  Entry points: `@sdxc/mail` for the mailer, renderer, MIME builder and layout kit;
27
27
  `@sdxc/mail/markdown` for markdown bodies; `@sdxc/mail/memory` and `@sdxc/mail/cloudflare`
28
- for transports; `@sdxc/mail/middleware` for the router middleware. A transport ships from
28
+ for transports; `@sdxc/mail/middleware` for the router middleware; `@sdxc/mail/unsubscribe`
29
+ for the endpoint behind a one-click unsubscribe link. A transport ships from
29
30
  its own subpath, so importing one never pulls another's platform dependency into a bundle.
30
31
 
31
32
  ## Usage
@@ -214,8 +215,8 @@ alone. `render()` applies this for you.
214
215
 
215
216
  ### `MailError`
216
217
 
217
- The single error type the package reports. The original provider or render error is kept
218
- as `cause`, so one log line can name the root problem.
218
+ The error every send failure reports. The original provider or render error is kept as
219
+ `cause`, so one log line can name the root problem.
219
220
 
220
221
  ### `Email` (layout kit)
221
222
 
@@ -279,6 +280,35 @@ interface Message {
279
280
  /** Explicit values; omitted means "now" and a generated id. Set both to keep tests deterministic. */
280
281
  date?: Date;
281
282
  messageId?: string;
283
+ /** Optional mail only; see "Bulk Mail". */
284
+ unsubscribe?: Unsubscribe;
285
+ list?: MailingList;
286
+ /** A calendar object sent beside the bodies; see "Calendar Invitations". */
287
+ calendar?: CalendarPart;
288
+ }
289
+
290
+ interface CalendarPart {
291
+ /** iTIP method written as `method=`, e.g. `REQUEST`; must match the content's `METHOD`. */
292
+ method: string;
293
+ /** The serialized iCalendar object. */
294
+ content: string;
295
+ /** Also attach the calendar as a file with this name, e.g. `invite.ics`. */
296
+ filename?: string;
297
+ }
298
+
299
+ interface Unsubscribe {
300
+ /** Must be `https:`; receives the RFC 8058 POST with no session. */
301
+ url: string | URL;
302
+ /** A bare address (sent with `?subject=unsubscribe`) or a full `mailto:` URI. */
303
+ mailto?: string;
304
+ /** `false` omits `List-Unsubscribe-Post`. Defaults to `true`. */
305
+ oneClick?: boolean;
306
+ }
307
+
308
+ interface MailingList {
309
+ /** RFC 2919 list-id, e.g. `digest.example.com`. */
310
+ id: string;
311
+ name?: string;
282
312
  }
283
313
 
284
314
  interface Email {
@@ -287,6 +317,9 @@ interface Email {
287
317
  body(): RemixElement;
288
318
  readonly replyTo?: Address | Address[];
289
319
  readonly headers?: Record<string, string>;
320
+ readonly unsubscribe?: Unsubscribe;
321
+ readonly list?: MailingList;
322
+ readonly calendar?: CalendarPart;
290
323
  }
291
324
 
292
325
  interface Transport {
@@ -301,7 +334,8 @@ interface SentMessage {
301
334
 
302
335
  `NormalizedMessage` is what a transport receives: every field of `Message` with defaults
303
336
  applied, address fields as lists, `date` and `messageId` always present, a derived `text`
304
- part, and `email` carrying the source `Email` when the message came from one — which
337
+ part, `headers` including the generated `List-*` ones, `unsubscribe` and `list` as the
338
+ option or `null`, `calendar` when one was set, and `email` carrying the source `Email` when the message came from one — which
305
339
  transports ignore and tests use to identify a send by type. `RenderedEmail` is
306
340
  `{ html, text }`, `SendOptions` is `Partial<Message>`, `MailerOptions` is the
307
341
  constructor's options object, `EmailTableRow` is one `{ label, value }` row of an
@@ -356,7 +390,10 @@ class. Success carries the identifier the platform assigned; a refusal arrives a
356
390
 
357
391
  The binding writes its own `Date` and `Message-ID`, so the values a normalized message
358
392
  carries stay local, and it takes one `replyTo` mailbox rather than a list, so the first is
359
- the one that ships. `SendEmailBinding`, `SendEmailMessage` and `SendEmailResult` are
393
+ the one that ships. Its payload has no field for an alternative part, so a `calendar`
394
+ travels as one attachment typed `text/calendar; method=…; charset=UTF-8`, named by
395
+ `filename` or `invite.ics`. `SendEmailBinding`, `SendEmailMessage`, `SendEmailAttachment`
396
+ and `SendEmailResult` are
360
397
  exported as the seam those platform assumptions live behind, declared locally so this
361
398
  package typechecks outside a Workers project. See the
362
399
  [Cloudflare email routing docs](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/)
@@ -377,13 +414,130 @@ out. With no log current the outcomes are dropped. The module augments `RequestC
377
414
  so `context.email` is typed wherever the middleware is imported. Despite the name, it is
378
415
  the object that _sends_ mail, not the current user's address.
379
416
 
417
+ ### `@sdxc/mail/unsubscribe`
418
+
419
+ #### `signUnsubscribeToken(secret, claims, options?): Promise<Result<string, MailError>>`
420
+
421
+ Signs `{ subject, list }` into one URL path segment: the hex HMAC-SHA-256 followed by the
422
+ base64url payload. `subject` must be an opaque id — an email address is refused, since the
423
+ token lands in URLs and logs — and `list` a name without colons. `options.expiresAt` makes
424
+ the link stop working; omitted, it never expires, because a provider may POST long after
425
+ delivery. `options.purpose` (default `"unsubscribe:v1:"`) prefixes the MAC input, so a
426
+ secret shared with something else cannot mint these tokens.
427
+
428
+ #### `verifyUnsubscribeToken(secret, token, options?): Promise<Result<UnsubscribeToken.Claims, InvalidUnsubscribeTokenError>>`
429
+
430
+ Checks the MAC in constant time and returns `{ subject, list, issuedAt }`. Malformed,
431
+ tampered, foreign-key, foreign-purpose and expired tokens all fail with the same
432
+ `InvalidUnsubscribeTokenError`, so the endpoint answers every case alike. `issuedAt` is
433
+ `null` for a token whose payload names only `list:subject`, the shape an app signing with
434
+ the same scheme by hand produces; pass that app's `purpose` to verify its links.
435
+
436
+ #### `isOneClickUnsubscribe(form: FormData): boolean`
437
+
438
+ Whether a parsed body is RFC 8058's `List-Unsubscribe=One-Click`, URL-encoded or
439
+ multipart. It tells the provider's POST, which reads no response, from a person pressing
440
+ the confirmation page's button.
441
+
442
+ ## Bulk Mail
443
+
444
+ Gmail and Yahoo require one-click unsubscribe on subscribed and marketing mail: a
445
+ `List-Unsubscribe` header with an HTTPS URL, `List-Unsubscribe-Post:
446
+ List-Unsubscribe=One-Click`, both covered by DKIM, and the unsubscribe honored within two
447
+ days. With those present the provider shows its own "Unsubscribe" button, and a recipient
448
+ who presses it stops receiving mail instead of reporting spam. Transactional mail
449
+ (verification, resets, sign-in alerts) is exempt and leaves the options unset.
450
+
451
+ An optional email declares its target and list; the mailer writes the headers:
452
+
453
+ ```typescript
454
+ import type { Email, MailingList, Unsubscribe } from "@sdxc/mail";
455
+
456
+ class DailyDigestEmail implements Email {
457
+ // to, subject, body() ...
458
+
459
+ get unsubscribe(): Unsubscribe {
460
+ return { url: this.digest.unsubscribeUrl, mailto: "unsubscribe@example.com" };
461
+ }
462
+
463
+ get list(): MailingList {
464
+ return { id: "daily-digest.example.com", name: "Daily digest" };
465
+ }
466
+ }
467
+ ```
468
+
469
+ ```text
470
+ List-Unsubscribe: <https://example.com/unsubscribe/…>, <mailto:unsubscribe@example.com?subject=unsubscribe>
471
+ List-Unsubscribe-Post: List-Unsubscribe=One-Click
472
+ List-Id: Daily digest <daily-digest.example.com>
473
+ ```
474
+
475
+ The send fails with a `MailError` when the URL is not `https:`, `mailto` names no address,
476
+ `list.id` is not a list-id, or the mailer, message or email headers already contain
477
+ `List-Unsubscribe`, `List-Unsubscribe-Post` or `List-Id` (any case) alongside the matching
478
+ option — a half-migrated email fails loudly instead of sending two targets. A hand-written
479
+ header keeps working while its option is unset.
480
+
481
+ The URL receives a POST from the provider's servers with no cookies, so it carries a
482
+ signed token and sits outside any auth guard. `GET` renders a confirmation and changes
483
+ nothing, because link scanners follow every URL; `POST` acts:
484
+
485
+ ```typescript
486
+ import { isOneClickUnsubscribe, verifyUnsubscribeToken } from "@sdxc/mail/unsubscribe";
487
+ import { isSuccess } from "@sdxc/result";
488
+
489
+ let claims = await verifyUnsubscribeToken(env.UNSUBSCRIBE_SECRET, token);
490
+ if (isSuccess(claims)) await unsubscribe(claims.data.subject, claims.data.list);
491
+ if (isOneClickUnsubscribe(formData)) return new Response(null, { status: 200 });
492
+ return renderDonePage();
493
+ ```
494
+
495
+ Rotating the secret breaks every link already delivered. DKIM coverage of the two headers
496
+ is decided by the transport's platform (Cloudflare's `send_email` binding signs for the
497
+ verified domain); check a delivered message's `h=` tag lists `list-unsubscribe` and
498
+ `list-unsubscribe-post`.
499
+
500
+ ## Calendar Invitations
501
+
502
+ An iTIP invitation (RFC 5546) is a calendar object sent as a `text/calendar` part whose
503
+ `method=` parameter repeats the object's `METHOD`; Gmail, Outlook and Apple Mail read it
504
+ and show the event with accept and decline buttons. A message or an email carries one as
505
+ `calendar`, with the content written by any iCalendar library:
506
+
507
+ ```typescript
508
+ await mailer.send({
509
+ to: { email: "ada@example.com" },
510
+ subject: "Scheduled maintenance on Saturday",
511
+ html: "<p>The database is read-only from 02:00 to 04:00 UTC.</p>",
512
+ calendar: { method: "REQUEST", content: ics, filename: "invite.ics" },
513
+ });
514
+ ```
515
+
516
+ `buildMimeMessage` places the calendar last in `multipart/alternative`, the most
517
+ preferred part, and with `filename` also attaches it as `application/ics` inside
518
+ `multipart/mixed`, for clients that only offer attachments:
519
+
520
+ ```text
521
+ multipart/mixed
522
+ ├── multipart/alternative
523
+ │ ├── text/plain; charset=utf-8
524
+ │ ├── text/html; charset=utf-8
525
+ │ └── text/calendar; method=REQUEST; charset=UTF-8
526
+ └── application/ics; name="invite.ics" (attachment)
527
+ ```
528
+
529
+ The send fails with a `MailError` when `method` is not a token or `filename` is not
530
+ printable ASCII free of quotes, backslashes and slashes, since both are written into
531
+ headers. A calendar still needs a body beside it, for clients that show no invitation.
532
+
380
533
  ## MIME Guarantees
381
534
 
382
535
  What `buildMimeMessage` produces, for a transport that speaks raw messages:
383
536
 
384
- - **Structure** — both body parts produce `multipart/alternative` with the plain-text part
385
- first, which RFC 2046 reads as least to most preferred; a single part produces a
386
- single-part message with no boundary.
537
+ - **Structure** — several parts produce `multipart/alternative` ordered plain text, HTML,
538
+ calendar, which RFC 2046 reads as least to most preferred; a single part produces a
539
+ single-part message with no boundary, and a calendar attachment wraps the body in
540
+ `multipart/mixed`.
387
541
  - **Headers** — `From`, `To`, `Cc`, `Reply-To`, `Subject`, `Date`, `Message-ID`,
388
542
  `MIME-Version`, then custom headers, then the `Content-*` headers. `Bcc` is absent,
389
543
  since those recipients are addressed by the envelope, and a custom header repeating a
@@ -35,6 +35,16 @@ export interface SendEmailMessage {
35
35
  html?: string;
36
36
  /** Extra headers to set on the assembled message. */
37
37
  headers?: Record<string, string>;
38
+ /** Files the platform attaches after the bodies. */
39
+ attachments?: SendEmailAttachment[];
40
+ }
41
+ /** A file the binding attaches to the message it composes. */
42
+ export interface SendEmailAttachment {
43
+ disposition: "attachment";
44
+ filename: string;
45
+ /** Media type of the file, parameters included. */
46
+ type: string;
47
+ content: string;
38
48
  }
39
49
  /** What the binding reports back for an accepted message. */
40
50
  export interface SendEmailResult {
@@ -17,6 +17,26 @@ function optionalAddresses(addresses) {
17
17
  return undefined;
18
18
  return addresses.map(formatAddress);
19
19
  }
20
+ /** Name a calendar attachment takes when the caller named none. */
21
+ const CALENDAR_FILENAME = "invite.ics";
22
+ /**
23
+ * Carries a calendar part as the one attachment the binding composes, since its
24
+ * payload has no alternative-part field; the `method=` parameter on the file's type
25
+ * is what calendar clients read to offer accept and decline.
26
+ */
27
+ function calendarAttachments(message) {
28
+ if (!message.calendar)
29
+ return undefined;
30
+ let { method, content, filename } = message.calendar;
31
+ return [
32
+ {
33
+ disposition: "attachment",
34
+ filename: filename ?? CALENDAR_FILENAME,
35
+ type: `text/calendar; method=${method}; charset=UTF-8`,
36
+ content,
37
+ },
38
+ ];
39
+ }
20
40
  /**
21
41
  * Maps a normalized message onto the binding's send payload. Only the first
22
42
  * reply-to mailbox survives, since the platform's field holds one address, so
@@ -34,6 +54,7 @@ function toPayload(message) {
34
54
  text: message.text,
35
55
  html: message.html,
36
56
  headers: message.headers,
57
+ attachments: calendarAttachments(message),
37
58
  };
38
59
  }
39
60
  /**
package/dist/errors.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The single error type the mail package reports. Every failure a caller can
3
- * branch on — invalid message, failed render, rejected delivery — arrives as a
4
- * `MailError` inside a `Result`, so no send path throws.
2
+ * The errors the mail package reports inside a `Result`: `MailError` for every
3
+ * send failure — invalid message, failed render, rejected delivery — and
4
+ * `InvalidUnsubscribeTokenError` for a token the unsubscribe endpoint refuses.
5
5
  *
6
6
  * @author [Sergio Xalambrí](https://sergiodxa.com)
7
7
  * @copyright Sergio Xalambrí 2026
@@ -20,3 +20,17 @@ export declare class MailError extends Error {
20
20
  */
21
21
  constructor(message: string, options?: ErrorOptions);
22
22
  }
23
+ /**
24
+ * A token the unsubscribe endpoint cannot act on: malformed, tampered with, signed
25
+ * with another secret or purpose, or expired. The cases share one type so the
26
+ * endpoint answers all of them the same way and leaks nothing about which failed.
27
+ */
28
+ export declare class InvalidUnsubscribeTokenError extends Error {
29
+ name: string;
30
+ /**
31
+ * Creates the error.
32
+ *
33
+ * @param options - Standard error options; `cause` keeps a crypto failure for logs.
34
+ */
35
+ constructor(options?: ErrorOptions);
36
+ }
package/dist/errors.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The single error type the mail package reports. Every failure a caller can
3
- * branch on — invalid message, failed render, rejected delivery — arrives as a
4
- * `MailError` inside a `Result`, so no send path throws.
2
+ * The errors the mail package reports inside a `Result`: `MailError` for every
3
+ * send failure — invalid message, failed render, rejected delivery — and
4
+ * `InvalidUnsubscribeTokenError` for a token the unsubscribe endpoint refuses.
5
5
  *
6
6
  * @author [Sergio Xalambrí](https://sergiodxa.com)
7
7
  * @copyright Sergio Xalambrí 2026
@@ -22,3 +22,19 @@ export class MailError extends Error {
22
22
  super(message, options);
23
23
  }
24
24
  }
25
+ /**
26
+ * A token the unsubscribe endpoint cannot act on: malformed, tampered with, signed
27
+ * with another secret or purpose, or expired. The cases share one type so the
28
+ * endpoint answers all of them the same way and leaks nothing about which failed.
29
+ */
30
+ export class InvalidUnsubscribeTokenError extends Error {
31
+ name = "InvalidUnsubscribeTokenError";
32
+ /**
33
+ * Creates the error.
34
+ *
35
+ * @param options - Standard error options; `cause` keeps a crypto failure for logs.
36
+ */
37
+ constructor(options) {
38
+ super("The unsubscribe token is invalid or has expired.", options);
39
+ }
40
+ }
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import type { Email as EmailContract } from "./types.js";
11
11
  import * as EmailComponents from "./components.js";
12
- export type { Address, Message, NormalizedMessage, SentMessage, Transport } from "./types.js";
12
+ export type { Address, CalendarPart, MailingList, Message, NormalizedMessage, SentMessage, Transport, Unsubscribe, } from "./types.js";
13
13
  export type { MailerOptions, SendOptions } from "./mailer.js";
14
14
  export type { RenderedEmail } from "./render.js";
15
15
  /**
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Builds the `List-Unsubscribe`, `List-Unsubscribe-Post` and `List-Id` headers from a
3
+ * message's structured options, so every sender gets the syntax RFC 2369, RFC 8058 and
4
+ * RFC 2919 require and a non-compliant target fails the send instead of the button.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { MailingList, Unsubscribe } from "../types.js";
11
+ import { MailError } from "../errors.js";
12
+ /**
13
+ * Generates the list headers a message's options ask for. A caller-written header that
14
+ * a set option would also generate fails the send, so a half-migrated email reports the
15
+ * mistake instead of shipping two different unsubscribe targets.
16
+ *
17
+ * @param headers - Every header already merged for the message: mailer, message and email.
18
+ * @param options - The message's `unsubscribe` and `list` options.
19
+ * @returns The headers to add, or a `MailError` naming the invalid option or the conflict.
20
+ */
21
+ export declare function buildListHeaders(headers: Record<string, string>, options: {
22
+ unsubscribe: Unsubscribe | null;
23
+ list: MailingList | null;
24
+ }): Result<Record<string, string>, MailError>;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Builds the `List-Unsubscribe`, `List-Unsubscribe-Post` and `List-Id` headers from a
3
+ * message's structured options, so every sender gets the syntax RFC 2369, RFC 8058 and
4
+ * RFC 2919 require and a non-compliant target fails the send instead of the button.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { failure, isFailure, success } from "@sdxc/result";
10
+ import { MailError } from "../errors.js";
11
+ import { formatAddress, isValidAddress } from "./address.js";
12
+ /** RFC 8058's fixed `List-Unsubscribe-Post` value, the only one providers act on. */
13
+ const ONE_CLICK = "List-Unsubscribe=One-Click";
14
+ /** RFC 2919 list-id: dot-atom text with at least two labels. */
15
+ const LIST_ID = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)+$/;
16
+ /** Control characters, which would split a header line or smuggle another header in. */
17
+ const CONTROL_CHARACTERS = /\p{Cc}/u;
18
+ /**
19
+ * Finds a caller-written header with the given name, compared case-insensitively as
20
+ * mail header names are, so a hand-written `list-id` conflicts with the generated one.
21
+ */
22
+ function findHeader(headers, names) {
23
+ return Object.keys(headers).find((key) => names.includes(key.toLowerCase()));
24
+ }
25
+ /**
26
+ * Resolves the unsubscribe target into its `List-Unsubscribe` value. The HTTPS URL
27
+ * comes first because RFC 8058 one-click requires it; a bare `mailto` address gets a
28
+ * subject so a client that composes the mail sends something a list server recognizes.
29
+ */
30
+ function unsubscribeValue(unsubscribe) {
31
+ let url = URL.parse(unsubscribe.url);
32
+ if (url?.protocol !== "https:") {
33
+ return failure(new MailError(`The unsubscribe URL "${unsubscribe.url}" must be https:.`));
34
+ }
35
+ let uris = [url.href];
36
+ if (unsubscribe.mailto !== undefined) {
37
+ let mailto = mailtoUri(unsubscribe.mailto);
38
+ if (mailto === null) {
39
+ return failure(new MailError(`"${unsubscribe.mailto}" is not an unsubscribe address.`));
40
+ }
41
+ uris.push(mailto);
42
+ }
43
+ return success(uris.map((uri) => `<${uri}>`).join(", "));
44
+ }
45
+ /**
46
+ * Turns the `mailto` option into a URI, or `null` when it names no routable address.
47
+ * A full URI keeps whatever query its author chose, normalized only by URL parsing so
48
+ * it cannot break out of its angle brackets.
49
+ */
50
+ function mailtoUri(mailto) {
51
+ if (!/^mailto:/i.test(mailto)) {
52
+ if (!isValidAddress({ email: mailto }))
53
+ return null;
54
+ return `mailto:${mailto}?subject=unsubscribe`;
55
+ }
56
+ let url = URL.parse(mailto);
57
+ if (!url || !isValidAddress({ email: decodeURIComponent(url.pathname) }))
58
+ return null;
59
+ return url.href;
60
+ }
61
+ /**
62
+ * Formats `List-Id`, whose id always sits in angle brackets, quoting a description the
63
+ * same way a mailbox's display name is quoted.
64
+ */
65
+ function listIdValue(list) {
66
+ if (!LIST_ID.test(list.id)) {
67
+ return failure(new MailError(`"${list.id}" is not a valid list id.`));
68
+ }
69
+ if (list.name !== undefined && CONTROL_CHARACTERS.test(list.name)) {
70
+ return failure(new MailError("A list name cannot contain control characters."));
71
+ }
72
+ if (!list.name?.trim())
73
+ return success(`<${list.id}>`);
74
+ return success(formatAddress({ email: list.id, name: list.name }));
75
+ }
76
+ /**
77
+ * Generates the list headers a message's options ask for. A caller-written header that
78
+ * a set option would also generate fails the send, so a half-migrated email reports the
79
+ * mistake instead of shipping two different unsubscribe targets.
80
+ *
81
+ * @param headers - Every header already merged for the message: mailer, message and email.
82
+ * @param options - The message's `unsubscribe` and `list` options.
83
+ * @returns The headers to add, or a `MailError` naming the invalid option or the conflict.
84
+ */
85
+ export function buildListHeaders(headers, options) {
86
+ let generated = {};
87
+ if (options.unsubscribe) {
88
+ let conflict = findHeader(headers, ["list-unsubscribe", "list-unsubscribe-post"]);
89
+ if (conflict) {
90
+ return failure(new MailError(`The "${conflict}" header conflicts with the unsubscribe option.`));
91
+ }
92
+ let value = unsubscribeValue(options.unsubscribe);
93
+ if (isFailure(value))
94
+ return value;
95
+ generated["List-Unsubscribe"] = value.data;
96
+ if (options.unsubscribe.oneClick !== false)
97
+ generated["List-Unsubscribe-Post"] = ONE_CLICK;
98
+ }
99
+ if (options.list) {
100
+ let conflict = findHeader(headers, ["list-id"]);
101
+ if (conflict) {
102
+ return failure(new MailError(`The "${conflict}" header conflicts with the list option.`));
103
+ }
104
+ let value = listIdValue(options.list);
105
+ if (isFailure(value))
106
+ return value;
107
+ generated["List-Id"] = value.data;
108
+ }
109
+ return success(generated);
110
+ }
package/dist/mailer.js CHANGED
@@ -12,7 +12,15 @@ import { failure, isFailure, success, wrap } from "@sdxc/result";
12
12
  import { MailError } from "./errors.js";
13
13
  import { isValidAddress, toAddressList } from "./lib/address.js";
14
14
  import { htmlToText } from "./lib/html-to-text.js";
15
+ import { buildListHeaders } from "./lib/list-headers.js";
15
16
  import { render } from "./render.js";
17
+ /** An iTIP method is an iana-token or x-name, which is all a `method=` parameter may hold. */
18
+ const METHOD = /^[A-Za-z0-9-]+$/;
19
+ /**
20
+ * Printable ASCII without quotes, backslashes or path separators, so the name travels in
21
+ * a quoted `filename` parameter unchanged and no client reads a directory into it.
22
+ */
23
+ const FILENAME = /^[\x20-\x21\x23-\x2E\x30-\x5B\x5D-\x7E]+$/;
16
24
  /**
17
25
  * Reports whether a value is an `Email` rather than a plain `Message`.
18
26
  * Discrimination is structural: a callable `body` is the one member only an email
@@ -50,9 +58,9 @@ function generateMessageId(from) {
50
58
  return `<${crypto.randomUUID()}@${domain}>`;
51
59
  }
52
60
  /**
53
- * Rejects messages no transport could deliver, so the failure names the mistake
54
- * instead of surfacing as a provider error later: a missing sender or recipient,
55
- * an address that cannot be routed, or a message with no body at all.
61
+ * Rejects messages no transport could deliver, so the failure names the mistake instead of
62
+ * a provider error: a missing sender or recipient, an unroutable address, no body at all,
63
+ * or a calendar method or filename that would break the header it is written into.
56
64
  */
57
65
  function validate(message) {
58
66
  if (!message.from.email)
@@ -69,6 +77,15 @@ function validate(message) {
69
77
  if (!message.html && !message.text) {
70
78
  return failure(new MailError("A message needs an HTML or a plain-text body."));
71
79
  }
80
+ if (message.calendar) {
81
+ let { method, filename } = message.calendar;
82
+ if (!METHOD.test(method)) {
83
+ return failure(new MailError(`"${method}" is not a calendar method.`));
84
+ }
85
+ if (filename !== undefined && !FILENAME.test(filename)) {
86
+ return failure(new MailError(`"${filename}" is not a calendar attachment filename.`));
87
+ }
88
+ }
72
89
  return success(message);
73
90
  }
74
91
  /**
@@ -155,14 +172,18 @@ export class Mailer {
155
172
  subject: input.subject,
156
173
  replyTo: input.replyTo,
157
174
  headers: input.headers,
175
+ unsubscribe: input.unsubscribe,
176
+ list: input.list,
177
+ calendar: input.calendar,
158
178
  html: rendered.data.html,
159
179
  text: rendered.data.text,
160
180
  });
161
181
  }
162
182
  /**
163
183
  * Produces the shape transports read: configured defaults filled in, address
164
- * lists coerced, a plain-text part derived, and the source email recorded for
165
- * type-based lookup. Validation runs last, so a failure describes the final message.
184
+ * lists coerced, a plain-text part derived, the `List-*` headers generated from
185
+ * their options, and the source email recorded for type-based lookup. Validation
186
+ * runs last, so a failure describes the final message.
166
187
  */
167
188
  async #normalize(input, overrides) {
168
189
  let converted = await this.#toMessage(input);
@@ -170,6 +191,12 @@ export class Mailer {
170
191
  return converted;
171
192
  let message = { ...converted.data, ...overrides };
172
193
  let from = message.from ?? this.#options.from;
194
+ let headers = { ...this.#options.headers, ...message.headers };
195
+ let unsubscribe = message.unsubscribe ?? null;
196
+ let list = message.list ?? null;
197
+ let listHeaders = buildListHeaders(headers, { unsubscribe, list });
198
+ if (isFailure(listHeaders))
199
+ return listHeaders;
173
200
  return validate({
174
201
  from,
175
202
  to: toAddressList(message.to),
@@ -179,9 +206,12 @@ export class Mailer {
179
206
  subject: message.subject,
180
207
  html: message.html,
181
208
  text: derivePlainText(converted.data, overrides),
182
- headers: { ...this.#options.headers, ...message.headers },
209
+ headers: { ...headers, ...listHeaders.data },
183
210
  date: message.date ?? new Date(),
184
211
  messageId: message.messageId ?? generateMessageId(from),
212
+ unsubscribe,
213
+ list,
214
+ calendar: message.calendar,
185
215
  email: isEmail(input) ? input : undefined,
186
216
  });
187
217
  }
package/dist/mime.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Assembles a normalized message into the raw RFC 5322 message a transport whose
3
3
  * provider takes MIME has to hand over: folded headers, encoded words for
4
- * non-ASCII text, a `multipart/alternative` body when both parts exist, and CRLF
4
+ * non-ASCII text, a `multipart/alternative` body when several parts exist, and CRLF
5
5
  * line endings throughout. It lives at the package root so any raw-MIME transport
6
6
  * can reuse it and so its tests need no runtime-specific import.
7
7
  *
@@ -10,9 +10,9 @@
10
10
  */
11
11
  import type { NormalizedMessage } from "./types.js";
12
12
  /**
13
- * Assembles a normalized message into a raw RFC 5322 message, ordering plain text
14
- * before HTML per RFC 2046 when both parts exist; with neither part, it yields an
15
- * empty text part, since validation guarantees that case never reaches a transport.
13
+ * Assembles a normalized message into a raw RFC 5322 message: one part is written
14
+ * alone, several go in `multipart/alternative`, and a calendar attachment wraps that in
15
+ * `multipart/mixed`. With no part at all it writes an empty text part.
16
16
  *
17
17
  * @param message - The normalized message a transport received.
18
18
  * @returns The complete message, ready to hand to a provider that takes raw MIME.
package/dist/mime.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Assembles a normalized message into the raw RFC 5322 message a transport whose
3
3
  * provider takes MIME has to hand over: folded headers, encoded words for
4
- * non-ASCII text, a `multipart/alternative` body when both parts exist, and CRLF
4
+ * non-ASCII text, a `multipart/alternative` body when several parts exist, and CRLF
5
5
  * line endings throughout. It lives at the package root so any raw-MIME transport
6
6
  * can reuse it and so its tests need no runtime-specific import.
7
7
  *
@@ -331,44 +331,80 @@ function buildHeaders(message) {
331
331
  }
332
332
  return headers;
333
333
  }
334
- /** Writes the `Content-*` headers that describe one part's type and encoding. */
335
- function buildContentHeaders(part) {
336
- return [
337
- foldHeader("Content-Type", `${part.contentType}; charset=utf-8`),
334
+ /** Writes the `Content-*` headers that describe one part's type, encoding and disposition. */
335
+ function toEntity(part) {
336
+ let headers = [
337
+ foldHeader("Content-Type", part.contentType),
338
338
  foldHeader("Content-Transfer-Encoding", part.encoding),
339
339
  ];
340
+ if (part.disposition)
341
+ headers.push(foldHeader("Content-Disposition", part.disposition));
342
+ return { headers, body: part.body };
340
343
  }
341
344
  /**
342
- * Assembles a normalized message into a raw RFC 5322 message, ordering plain text
343
- * before HTML per RFC 2046 when both parts exist; with neither part, it yields an
344
- * empty text part, since validation guarantees that case never reaches a transport.
345
+ * Nests entities in one multipart entity. Each delimiter line is its own line, and the
346
+ * boundary is checked against the nested bodies, inner boundaries included.
347
+ */
348
+ function toMultipart(subtype, entities) {
349
+ let boundary = selectBoundary(entities);
350
+ let lines = [];
351
+ for (let entity of entities)
352
+ lines.push(`--${boundary}`, ...entity.headers, "", entity.body);
353
+ lines.push(`--${boundary}--`);
354
+ return {
355
+ headers: [foldHeader("Content-Type", `multipart/${subtype}; boundary="${boundary}"`)],
356
+ body: lines.join(CRLF),
357
+ };
358
+ }
359
+ /**
360
+ * The alternative parts, in increasing order of preference as RFC 2046 orders them:
361
+ * plain text, HTML, then the calendar, which clients that understand iTIP render as
362
+ * an invitation and every other client skips.
363
+ */
364
+ function alternativeParts(message) {
365
+ let parts = [];
366
+ if (message.text)
367
+ parts.push(encodePart("text/plain; charset=utf-8", message.text));
368
+ if (message.html)
369
+ parts.push(encodePart("text/html; charset=utf-8", message.html));
370
+ if (message.calendar) {
371
+ let type = `text/calendar; method=${message.calendar.method}; charset=UTF-8`;
372
+ parts.push(encodePart(type, message.calendar.content));
373
+ }
374
+ return parts;
375
+ }
376
+ /**
377
+ * The calendar as a downloadable file. It is typed `application/ics` so a client that
378
+ * already acts on the `text/calendar` alternative does not process the invitation twice.
379
+ */
380
+ function calendarAttachment(message) {
381
+ let filename = message.calendar?.filename;
382
+ if (!message.calendar || filename === undefined)
383
+ return undefined;
384
+ return {
385
+ contentType: `application/ics; name="${filename}"`,
386
+ encoding: "base64",
387
+ disposition: `attachment; filename="${filename}"`,
388
+ body: toBase64Body(message.calendar.content),
389
+ };
390
+ }
391
+ /**
392
+ * Assembles a normalized message into a raw RFC 5322 message: one part is written
393
+ * alone, several go in `multipart/alternative`, and a calendar attachment wraps that in
394
+ * `multipart/mixed`. With no part at all it writes an empty text part.
345
395
  *
346
396
  * @param message - The normalized message a transport received.
347
397
  * @returns The complete message, ready to hand to a provider that takes raw MIME.
348
398
  * @example let raw = buildMimeMessage(message); // "From: ...\r\n\r\n..."
349
399
  */
350
400
  export function buildMimeMessage(message) {
351
- let text = message.text ? encodePart("text/plain", message.text) : undefined;
352
- let html = message.html ? encodePart("text/html", message.html) : undefined;
353
- let headers = buildHeaders(message);
354
- if (text && html) {
355
- let boundary = selectBoundary([text, html]);
356
- return [
357
- ...headers,
358
- foldHeader("Content-Type", `multipart/alternative; boundary="${boundary}"`),
359
- "",
360
- `--${boundary}`,
361
- ...buildContentHeaders(text),
362
- "",
363
- text.body,
364
- `--${boundary}`,
365
- ...buildContentHeaders(html),
366
- "",
367
- html.body,
368
- `--${boundary}--`,
369
- "",
370
- ].join(CRLF);
371
- }
372
- let part = text ?? html ?? encodePart("text/plain", "");
373
- return [...headers, ...buildContentHeaders(part), "", part.body, ""].join(CRLF);
401
+ let parts = alternativeParts(message);
402
+ if (parts.length === 0)
403
+ parts.push(encodePart("text/plain; charset=utf-8", ""));
404
+ let [only] = parts;
405
+ let body = parts.length === 1 && only ? toEntity(only) : toMultipart("alternative", parts.map(toEntity));
406
+ let attachment = calendarAttachment(message);
407
+ if (attachment)
408
+ body = toMultipart("mixed", [body, toEntity(attachment)]);
409
+ return [...buildHeaders(message), ...body.headers, "", body.body, ""].join(CRLF);
374
410
  }
package/dist/types.d.ts CHANGED
@@ -16,6 +16,48 @@ export interface Address {
16
16
  /** Display name shown by mail clients in place of the bare address. */
17
17
  name?: string;
18
18
  }
19
+ /**
20
+ * An RFC 8058 one-click unsubscribe target, from which the mailer writes
21
+ * `List-Unsubscribe` and `List-Unsubscribe-Post`. Mailbox providers show their own
22
+ * unsubscribe button only for an HTTPS target they can POST to without a session.
23
+ */
24
+ export interface Unsubscribe {
25
+ /**
26
+ * The one-click endpoint, which must be `https:`. It receives the provider's POST
27
+ * with no cookies, so the URL alone has to identify the recipient and the list.
28
+ */
29
+ url: string | URL;
30
+ /** An address, or a full `mailto:` URI, listed after the URL for clients that only mail. */
31
+ mailto?: string;
32
+ /**
33
+ * Writes `List-Unsubscribe-Post`; set `false` only for a URL that cannot accept the POST.
34
+ * @default true
35
+ */
36
+ oneClick?: boolean;
37
+ }
38
+ /** The mailing list a message belongs to, written as RFC 2919 `List-Id` for filters and feedback loops. */
39
+ export interface MailingList {
40
+ /** Dot-separated labels in a domain the sender controls, e.g. `digest.uptime.example.com`. */
41
+ id: string;
42
+ /** Description shown before the id: `List-Id: Daily digest <digest.uptime.example.com>`. */
43
+ name?: string;
44
+ }
45
+ /**
46
+ * A calendar object carried beside the bodies, which is how an iTIP invitation reaches a
47
+ * calendar client: raw MIME places it last in `multipart/alternative` as
48
+ * `text/calendar; method=…`, so clients that understand it show accept and decline.
49
+ */
50
+ export interface CalendarPart {
51
+ /** The iTIP method, e.g. `REQUEST`, `CANCEL` or `REPLY`; it must match the content's `METHOD`. */
52
+ method: string;
53
+ /** The serialized iCalendar object, CRLF lines as a calendar writer produces them. */
54
+ content: string;
55
+ /**
56
+ * Attaches the same calendar as a file with this name, e.g. `invite.ics`, for clients that
57
+ * only offer attachments; transports with no alternative part use it as the file's name.
58
+ */
59
+ filename?: string;
60
+ }
19
61
  /**
20
62
  * A message as callers write it. Everything the mailer can supply a default for
21
63
  * is optional, so a caller only states what is specific to this message.
@@ -43,6 +85,12 @@ export interface Message {
43
85
  date?: Date;
44
86
  /** Explicit `Message-ID` value; omitted means generated. Set it to keep tests deterministic. */
45
87
  messageId?: string;
88
+ /** One-click unsubscribe for optional mail; transactional mail leaves it unset. */
89
+ unsubscribe?: Unsubscribe;
90
+ /** List the message belongs to, written as `List-Id`. */
91
+ list?: MailingList;
92
+ /** A calendar object, such as an invitation, sent alongside the bodies. */
93
+ calendar?: CalendarPart;
46
94
  }
47
95
  /**
48
96
  * A message after normalization: defaults applied, every address list coerced to
@@ -66,12 +114,21 @@ export interface NormalizedMessage {
66
114
  html?: string;
67
115
  /** Plain-text body; derived from `html` unless the caller supplied one. */
68
116
  text?: string;
69
- /** Headers to add, with per-message values winning over configured ones. */
117
+ /**
118
+ * Headers to add, with per-message values winning over configured ones, plus the
119
+ * `List-*` headers generated from `unsubscribe` and `list`.
120
+ */
70
121
  headers: Record<string, string>;
71
122
  /** Value for the `Date` header, defaulted to the moment of normalization. */
72
123
  date: Date;
73
124
  /** Value for the `Message-ID` header, generated when the caller omitted it. */
74
125
  messageId: string;
126
+ /** The unsubscribe target the `List-Unsubscribe` headers were written from, when set. */
127
+ unsubscribe: Unsubscribe | null;
128
+ /** The list the `List-Id` header was written from, when set. */
129
+ list: MailingList | null;
130
+ /** The calendar object to send alongside the bodies, when set. */
131
+ calendar?: CalendarPart;
75
132
  /**
76
133
  * The email object this message was produced from, when it came from one.
77
134
  * It exists so tests can identify a sent message by its type.
@@ -113,4 +170,10 @@ export interface Email {
113
170
  readonly replyTo?: Address | Address[];
114
171
  /** Per-email headers, merged over the mailer's configured headers. */
115
172
  readonly headers?: Record<string, string>;
173
+ /** One-click unsubscribe for optional mail; transactional emails leave it unset. */
174
+ readonly unsubscribe?: Unsubscribe;
175
+ /** List the email belongs to, written as `List-Id`. */
176
+ readonly list?: MailingList;
177
+ /** A calendar object, such as an invitation, sent alongside the rendered body. */
178
+ readonly calendar?: CalendarPart;
116
179
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The receiving side of one-click unsubscribe: HMAC-signed tokens that let an
3
+ * unsubscribe URL name the recipient and list with no session and no stored row, and
4
+ * a recognizer for the RFC 8058 POST a mailbox provider sends to that URL.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import { InvalidUnsubscribeTokenError, MailError } from "./errors.js";
11
+ export { InvalidUnsubscribeTokenError } from "./errors.js";
12
+ /** Types for the unsubscribe token helpers. */
13
+ export declare namespace UnsubscribeToken {
14
+ /** What a verified token authorizes. */
15
+ interface Claims {
16
+ /** Who is unsubscribing: an opaque id, never an email address, since the token lands in URLs and logs. */
17
+ subject: string;
18
+ /** What they are unsubscribing from, matching the app's own list or email-kind name. */
19
+ list: string;
20
+ /** When the token was signed; `null` for a token whose payload names only the list and subject. */
21
+ issuedAt: Date | null;
22
+ }
23
+ /** Options both signing and verifying accept; they must agree for a token to verify. */
24
+ interface Options {
25
+ /**
26
+ * Domain-separation prefix for the MAC, so a secret shared with sessions or webhooks
27
+ * cannot be used to mint an unsubscribe token.
28
+ * @default "unsubscribe:v1:"
29
+ */
30
+ purpose?: string;
31
+ }
32
+ /** Options for signing a token. */
33
+ interface SignOptions extends Options {
34
+ /** When the link stops working; omitted means never, since providers may POST long after delivery. */
35
+ expiresAt?: Date;
36
+ }
37
+ }
38
+ /**
39
+ * Signs an unsubscribe token: the hex HMAC-SHA-256 followed by the base64url payload, so
40
+ * the whole token is one URL path segment that needs no escaping. The subject must be
41
+ * an opaque id; an email address is refused because the token travels in URLs and logs.
42
+ *
43
+ * @param secret - The app's unsubscribe secret; rotating it invalidates every delivered link.
44
+ * @param claims - The recipient's opaque id and the list they leave.
45
+ * @param options - An expiry, and the purpose prefix the verifier must also use.
46
+ * @returns The token, or a `MailError` for unusable claims or a failed signature.
47
+ * @example let token = await signUnsubscribeToken(env.UNSUBSCRIBE_SECRET, { subject: member.id, list: "digest" });
48
+ */
49
+ export declare function signUnsubscribeToken(secret: string, claims: {
50
+ subject: string;
51
+ list: string;
52
+ }, options?: UnsubscribeToken.SignOptions): Promise<Result<string, MailError>>;
53
+ /**
54
+ * Verifies a token from an unsubscribe URL in constant time and returns what it
55
+ * authorizes. Every way a token can be unusable arrives as the same error, so the
56
+ * endpoint answers them alike.
57
+ *
58
+ * @param secret - The secret the token was signed with.
59
+ * @param token - The path segment from the unsubscribe URL.
60
+ * @param options - The purpose prefix the token was signed with.
61
+ * @returns The claims, or an `InvalidUnsubscribeTokenError`.
62
+ */
63
+ export declare function verifyUnsubscribeToken(secret: string, token: string, options?: UnsubscribeToken.Options): Promise<Result<UnsubscribeToken.Claims, InvalidUnsubscribeTokenError>>;
64
+ /**
65
+ * Reports whether a parsed form body is RFC 8058's `List-Unsubscribe=One-Click`, which
66
+ * is how an endpoint tells the mailbox provider's POST, which reads no response body,
67
+ * from a person pressing the confirmation page's button.
68
+ *
69
+ * @param form - The request's parsed form data, URL-encoded or multipart.
70
+ * @returns `true` only for the exact field and value RFC 8058 defines.
71
+ */
72
+ export declare function isOneClickUnsubscribe(form: FormData): boolean;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The receiving side of one-click unsubscribe: HMAC-signed tokens that let an
3
+ * unsubscribe URL name the recipient and list with no session and no stored row, and
4
+ * a recognizer for the RFC 8058 POST a mailbox provider sends to that URL.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { Base64Url, Hex, hmac } from "@sdxc/crypto";
10
+ import { failure, isFailure, success } from "@sdxc/result";
11
+ import { InvalidUnsubscribeTokenError, MailError } from "./errors.js";
12
+ import { isValidAddress } from "./lib/address.js";
13
+ export { InvalidUnsubscribeTokenError } from "./errors.js";
14
+ /**
15
+ * Prefixed to every signed payload unless a caller names its own, so a MAC made here
16
+ * authenticates only an unsubscribe even when the secret also signs something else.
17
+ */
18
+ const DEFAULT_PURPOSE = "unsubscribe:v1:";
19
+ /** Length of the hex SHA-256 MAC that opens every token, which is what lets it need no separator. */
20
+ const SIGNATURE_LENGTH = 64;
21
+ /** Control characters, which the payload uses as its field separator. */
22
+ const CONTROL_CHARACTERS = /\p{Cc}/u;
23
+ /**
24
+ * Serializes the claims as `list:subject`, followed by the issue time and optional
25
+ * expiry in Unix seconds on their own lines. The list holds no colon and neither field
26
+ * a control character, so the first colon and each line break split it unambiguously.
27
+ */
28
+ function encodePayload(claims, issuedAt, expiresAt) {
29
+ let lines = [`${claims.list}:${claims.subject}`, String(Math.floor(issuedAt.getTime() / 1000))];
30
+ if (expiresAt)
31
+ lines.push(String(Math.floor(expiresAt.getTime() / 1000)));
32
+ return lines.join("\n");
33
+ }
34
+ /**
35
+ * Reads a payload back, or `null` when it is malformed or expired at `now`. A payload
36
+ * with only `list:subject` verifies with a `null` issue time and no expiry.
37
+ */
38
+ function decodePayload(payload, now) {
39
+ let [head = "", issued, expires, ...rest] = payload.split("\n");
40
+ if (rest.length > 0)
41
+ return null;
42
+ let separator = head.indexOf(":");
43
+ let list = head.slice(0, separator);
44
+ let subject = head.slice(separator + 1);
45
+ if (separator <= 0 || !subject)
46
+ return null;
47
+ let issuedAt = issued === undefined ? null : fromSeconds(issued);
48
+ if (issuedAt === undefined)
49
+ return null;
50
+ if (expires !== undefined) {
51
+ let expiresAt = fromSeconds(expires);
52
+ if (!expiresAt || now >= expiresAt)
53
+ return null;
54
+ }
55
+ return { subject, list, issuedAt };
56
+ }
57
+ /** Parses a decimal Unix-seconds field, or `undefined` for anything else. */
58
+ function fromSeconds(value) {
59
+ if (!/^\d+$/.test(value))
60
+ return undefined;
61
+ return new Date(Number(value) * 1000);
62
+ }
63
+ /**
64
+ * Signs an unsubscribe token: the hex HMAC-SHA-256 followed by the base64url payload, so
65
+ * the whole token is one URL path segment that needs no escaping. The subject must be
66
+ * an opaque id; an email address is refused because the token travels in URLs and logs.
67
+ *
68
+ * @param secret - The app's unsubscribe secret; rotating it invalidates every delivered link.
69
+ * @param claims - The recipient's opaque id and the list they leave.
70
+ * @param options - An expiry, and the purpose prefix the verifier must also use.
71
+ * @returns The token, or a `MailError` for unusable claims or a failed signature.
72
+ * @example let token = await signUnsubscribeToken(env.UNSUBSCRIBE_SECRET, { subject: member.id, list: "digest" });
73
+ */
74
+ export async function signUnsubscribeToken(secret, claims, options = {}) {
75
+ if (!claims.list || claims.list.includes(":") || CONTROL_CHARACTERS.test(claims.list)) {
76
+ return failure(new MailError("An unsubscribe list must be a non-empty name without colons."));
77
+ }
78
+ if (!claims.subject || CONTROL_CHARACTERS.test(claims.subject)) {
79
+ return failure(new MailError("An unsubscribe subject must be a non-empty opaque id."));
80
+ }
81
+ if (isValidAddress({ email: claims.subject })) {
82
+ return failure(new MailError("An unsubscribe subject must be an opaque id, not an address."));
83
+ }
84
+ let payload = encodePayload(claims, new Date(), options.expiresAt);
85
+ let purpose = options.purpose ?? DEFAULT_PURPOSE;
86
+ let mac = await hmac.sign(secret, `${purpose}${payload}`);
87
+ if (isFailure(mac)) {
88
+ return failure(new MailError("Failed to sign the unsubscribe token.", { cause: mac.error }));
89
+ }
90
+ return success(`${Hex.encode(mac.data)}${Base64Url.encode(payload)}`);
91
+ }
92
+ /**
93
+ * Verifies a token from an unsubscribe URL in constant time and returns what it
94
+ * authorizes. Every way a token can be unusable arrives as the same error, so the
95
+ * endpoint answers them alike.
96
+ *
97
+ * @param secret - The secret the token was signed with.
98
+ * @param token - The path segment from the unsubscribe URL.
99
+ * @param options - The purpose prefix the token was signed with.
100
+ * @returns The claims, or an `InvalidUnsubscribeTokenError`.
101
+ */
102
+ export async function verifyUnsubscribeToken(secret, token, options = {}) {
103
+ let signature = token.slice(0, SIGNATURE_LENGTH);
104
+ let encoded = token.slice(SIGNATURE_LENGTH);
105
+ if (signature.length < SIGNATURE_LENGTH || !encoded) {
106
+ return failure(new InvalidUnsubscribeTokenError());
107
+ }
108
+ let decoded = Base64Url.decode(encoded);
109
+ if (isFailure(decoded))
110
+ return failure(new InvalidUnsubscribeTokenError());
111
+ let payload = new TextDecoder().decode(decoded.data);
112
+ let purpose = options.purpose ?? DEFAULT_PURPOSE;
113
+ let valid = await hmac.verify(secret, `${purpose}${payload}`, signature);
114
+ if (isFailure(valid))
115
+ return failure(new InvalidUnsubscribeTokenError({ cause: valid.error }));
116
+ if (!valid.data)
117
+ return failure(new InvalidUnsubscribeTokenError());
118
+ let claims = decodePayload(payload, new Date());
119
+ if (!claims)
120
+ return failure(new InvalidUnsubscribeTokenError());
121
+ return success(claims);
122
+ }
123
+ /**
124
+ * Reports whether a parsed form body is RFC 8058's `List-Unsubscribe=One-Click`, which
125
+ * is how an endpoint tells the mailbox provider's POST, which reads no response body,
126
+ * from a person pressing the confirmation page's button.
127
+ *
128
+ * @param form - The request's parsed form data, URL-encoded or multipart.
129
+ * @returns `true` only for the exact field and value RFC 8058 defines.
130
+ */
131
+ export function isOneClickUnsubscribe(form) {
132
+ return form.get("List-Unsubscribe") === "One-Click";
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/mail",
3
- "version": "2026.9.15",
3
+ "version": "2026.9.29",
4
4
  "description": "Transport-agnostic transactional email with pluggable transports and remix/ui rendering",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,16 +9,18 @@
9
9
  "./markdown": "./dist/markdown.js",
10
10
  "./memory": "./dist/memory.js",
11
11
  "./middleware": "./dist/middleware.js",
12
- "./cloudflare": "./dist/cloudflare.js"
12
+ "./cloudflare": "./dist/cloudflare.js",
13
+ "./unsubscribe": "./dist/unsubscribe.js"
13
14
  },
14
15
  "dependencies": {
15
- "@sdxc/highlight": "2026.9.15",
16
- "@sdxc/logger": "2026.9.11",
17
- "@sdxc/markdown": "2026.9.15",
16
+ "@sdxc/crypto": "2026.9.28",
17
+ "@sdxc/highlight": "2026.9.29",
18
+ "@sdxc/logger": "2026.9.29",
19
+ "@sdxc/markdown": "2026.9.29",
18
20
  "@sdxc/result": "2026.9.15",
19
- "remix": "3.0.0-rc.2"
21
+ "remix": "3.0.0-rc.4"
20
22
  },
21
- "gitHead": "d56f75a79171aab3be101d8fa624d51972cd6028",
23
+ "gitHead": "c17409b830b01b93514feb67818e3b1dcf654f5a",
22
24
  "publishConfig": {
23
25
  "access": "public"
24
26
  },