@sdxc/mail 2026.9.15 → 2026.9.28
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 +162 -8
- package/dist/cloudflare.d.ts +10 -0
- package/dist/cloudflare.js +21 -0
- package/dist/errors.d.ts +17 -3
- package/dist/errors.js +19 -3
- package/dist/index.d.ts +1 -1
- package/dist/lib/list-headers.d.ts +24 -0
- package/dist/lib/list-headers.js +110 -0
- package/dist/mailer.js +36 -6
- package/dist/mime.d.ts +4 -4
- package/dist/mime.js +67 -31
- package/dist/types.d.ts +64 -1
- package/dist/unsubscribe.d.ts +72 -0
- package/dist/unsubscribe.js +133 -0
- package/package.json +7 -5
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
|
|
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
|
|
218
|
-
|
|
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,
|
|
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.
|
|
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** —
|
|
385
|
-
|
|
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
|
package/dist/cloudflare.d.ts
CHANGED
|
@@ -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 {
|
package/dist/cloudflare.js
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
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
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
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
|
-
*
|
|
55
|
-
*
|
|
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,
|
|
165
|
-
*
|
|
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: { ...
|
|
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
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
|
335
|
-
function
|
|
336
|
-
|
|
337
|
-
foldHeader("Content-Type",
|
|
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
|
-
*
|
|
343
|
-
*
|
|
344
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
/**
|
|
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.
|
|
3
|
+
"version": "2026.9.28",
|
|
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/
|
|
16
|
+
"@sdxc/crypto": "2026.9.28",
|
|
17
|
+
"@sdxc/highlight": "2026.9.28",
|
|
16
18
|
"@sdxc/logger": "2026.9.11",
|
|
17
|
-
"@sdxc/markdown": "2026.9.
|
|
19
|
+
"@sdxc/markdown": "2026.9.28",
|
|
18
20
|
"@sdxc/result": "2026.9.15",
|
|
19
21
|
"remix": "3.0.0-rc.2"
|
|
20
22
|
},
|
|
21
|
-
"gitHead": "
|
|
23
|
+
"gitHead": "00c01e349b11f65b9f47902c5345a817e8afd2b1",
|
|
22
24
|
"publishConfig": {
|
|
23
25
|
"access": "public"
|
|
24
26
|
},
|