@sdxc/mail 2026.10.10 → 2026.10.13

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
@@ -1,37 +1,26 @@
1
1
  # @sdxc/mail
2
2
 
3
- Transport-agnostic transactional email: pluggable transports, one mailer that normalizes
4
- every message, and a `remix/component` layout kit that renders both body parts.
3
+ Transport-agnostic transactional email with pluggable transports and `remix/component` rendering.
5
4
 
6
- A `Mailer` owns normalization — sender defaults, address coercion, plain-text derivation,
7
- validation — and hands the result to a `Transport`, the only piece that knows a provider.
8
- Delivery is a value rather than an exception: `send()` returns a `Result` and never throws.
5
+ A `Mailer` applies sender defaults, coerces addresses, derives the plain-text part and validates
6
+ each message, then hands it to a `Transport`, the only piece that knows a provider. `send()`
7
+ answers a `Result` and never throws.
9
8
 
10
9
  ## Installation
11
10
 
12
- ```bash
11
+ ```sh
13
12
  npm add @sdxc/mail
14
13
  ```
15
14
 
16
- Send outcomes are reported as a `Result` from
17
- [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), which is where `isFailure`
18
- and `unwrap` come from, and bodies are `remix/component` trees from
19
- [`remix`](https://www.npmjs.com/package/remix). The middleware records deferred sends on
20
- the current log from [`@sdxc/logger`](https://www.npmjs.com/package/@sdxc/logger), and
21
- `@sdxc/mail/markdown` renders documents parsed by
22
- [`@sdxc/markdown`](https://www.npmjs.com/package/@sdxc/markdown), painted by
23
- [`@sdxc/highlight`](https://www.npmjs.com/package/@sdxc/highlight). All four install
24
- alongside this package.
25
-
26
- Entry points: `@sdxc/mail` for the mailer, renderer, MIME builder and layout kit;
27
- `@sdxc/mail/markdown` for markdown bodies; `@sdxc/mail/memory` and `@sdxc/mail/cloudflare`
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
30
- its own subpath, so importing one never pulls another's platform dependency into a bundle.
15
+ Outcomes are `Result`s from [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) and
16
+ bodies are `remix/component` trees from [`remix`](https://www.npmjs.com/package/remix). The
17
+ middleware logs through [`@sdxc/logger`](https://www.npmjs.com/package/@sdxc/logger), and
18
+ `./markdown` renders documents from [`@sdxc/markdown`](https://www.npmjs.com/package/@sdxc/markdown)
19
+ painted by [`@sdxc/highlight`](https://www.npmjs.com/package/@sdxc/highlight).
31
20
 
32
21
  ## Usage
33
22
 
34
- ### Send A Message
23
+ ### Send a message
35
24
 
36
25
  ```typescript
37
26
  import { Mailer } from "@sdxc/mail";
@@ -53,33 +42,27 @@ let result = await mailer.send({
53
42
  if (isFailure(result)) console.warn(result.error.message);
54
43
  ```
55
44
 
56
- The plain-text part is derived from the HTML, so a message ships both parts without a
57
- second authoring step.
45
+ The plain-text part is derived from the HTML, so every message ships both.
58
46
 
59
- ### Author An Email As A Class
47
+ ### Author an email as a class
60
48
 
61
49
  ```tsx
62
- import type { Email as EmailContract } from "@sdxc/mail";
63
50
  import type { Handle } from "remix/component";
64
51
 
65
52
  import { Email } from "@sdxc/mail";
66
53
 
67
54
  function InviteBody(handle: Handle<{ team: string; url: string }>) {
68
- return () => {
69
- let { team, url } = handle.props;
70
-
71
- return (
72
- <Email.Layout preview={`Join ${team}`} title="Invite">
73
- <Email.Heading>You have been invited</Email.Heading>
74
- <Email.Text>Join {team} to keep an eye on your services.</Email.Text>
75
- <Email.Button href={url}>Accept invite</Email.Button>
76
- <Email.Footer>You received this because someone invited you to a team.</Email.Footer>
77
- </Email.Layout>
78
- );
79
- };
55
+ return () => (
56
+ <Email.Layout preview={`Join ${handle.props.team}`} title="Invite">
57
+ <Email.Heading>You have been invited</Email.Heading>
58
+ <Email.Text>Join {handle.props.team} to start collaborating.</Email.Text>
59
+ <Email.Button href={handle.props.url}>Accept invite</Email.Button>
60
+ <Email.Footer>You received this because someone invited you to a team.</Email.Footer>
61
+ </Email.Layout>
62
+ );
80
63
  }
81
64
 
82
- export class TeamInviteEmail implements EmailContract {
65
+ export class TeamInviteEmail implements Email {
83
66
  constructor(private invite: { team: string; email: string; url: string; subject: string }) {}
84
67
 
85
68
  get to() {
@@ -94,13 +77,15 @@ export class TeamInviteEmail implements EmailContract {
94
77
  return <InviteBody team={this.invite.team} url={this.invite.url} />;
95
78
  }
96
79
  }
80
+
81
+ await mailer.send(new TeamInviteEmail(invite));
97
82
  ```
98
83
 
99
- `Email` names both the contract in type space and the layout kit in value space, so
100
- `implements Email` and `<Email.Layout>` come from one import. Hand the class straight to
101
- `send()`: `await mailer.send(new TeamInviteEmail(invite))`.
84
+ `Email` is both the contract (type) and the layout kit (value), so one import covers
85
+ `implements Email` and `<Email.Layout>`. The subject arrives as a string, already in the
86
+ recipient's language.
102
87
 
103
- ### Publish A Mailer On The Request Context
88
+ ### Publish a mailer on the request context
104
89
 
105
90
  ```typescript
106
91
  import { CloudflareTransport } from "@sdxc/mail/cloudflare";
@@ -123,10 +108,10 @@ router.post("/invites", async (context) => {
123
108
  });
124
109
  ```
125
110
 
126
- `context.email` is a request-scoped `Mailer`. Whatever `later()` queued is flushed after
127
- the response is produced, and each outcome lands on the invocation's log.
111
+ `context.email` is a request-scoped `Mailer`; what `later()` queued is sent once the response
112
+ is produced, and each outcome lands on the invocation's log.
128
113
 
129
- ### Test Without Mocking A Provider
114
+ ### Test against the memory transport
130
115
 
131
116
  ```typescript
132
117
  import { Mailer } from "@sdxc/mail";
@@ -142,33 +127,30 @@ expect(transport.last?.email).toBeInstanceOf(TeamInviteEmail);
142
127
  expect(transport.last?.text).toContain(invite.url);
143
128
  ```
144
129
 
145
- Recorded messages are the normalized ones a provider would have received, so defaults,
146
- coerced address lists and the derived text part are all visible.
130
+ Recorded messages are the normalized ones a provider receives: defaults applied, address
131
+ lists coerced and the text part derived.
147
132
 
148
133
  ## API
149
134
 
150
135
  ### `new Mailer(options: MailerOptions)`
151
136
 
152
- Sends mail through a transport, applying one app's sender identity to every message.
153
- `options` carries `transport`, `from`, and optionally `replyTo` and `headers` — the last
154
- added to every message, with per-message headers winning.
137
+ A mailer bound to one transport and sender identity. `options` holds `transport`, `from`, and
138
+ optionally `replyTo` and `headers`; per-message headers win over `headers`.
155
139
 
156
- ### `mailer.send(input, overrides?): Promise<Result<SentMessage, MailError>>`
140
+ ### `mailer.send(input: Message | Email, overrides?: SendOptions): Promise<Result<SentMessage, MailError>>`
157
141
 
158
- Normalizes and delivers one message, awaiting the outcome. Never throws: a render
159
- failure, an invalid message, a rejected delivery and a transport that throws all arrive as
160
- a `MailError` failure. `input` is a plain `Message` or an `Email`; `overrides` replace
161
- fields for this send only.
142
+ Normalizes and delivers one message. A render failure, an invalid message, a rejected delivery
143
+ and a throwing transport all answer a `MailError` failure. `overrides` replace fields for this
144
+ send only.
162
145
 
163
- ### `mailer.later(input, overrides?): void`
146
+ ### `mailer.later(input: Message | Email, overrides?: SendOptions): void`
164
147
 
165
- Queues a message for the next `flush()`. Nothing is rendered or validated yet, which is
166
- what gives a fire-and-forget send a defined lifetime.
148
+ Queues a message for the next `flush()`; rendering and validation happen at flush time.
167
149
 
168
150
  ### `mailer.flush(): Promise<Result<SentMessage, MailError>[]>`
169
151
 
170
- Sends everything `later()` queued and empties the queue, returning one result per message
171
- in queue order. Never throws. The middleware calls this for you.
152
+ Sends and empties the queue, answering one result per message in queue order. The middleware
153
+ calls it for you.
172
154
 
173
155
  ### `mailer.pending: number`
174
156
 
@@ -176,294 +158,216 @@ How many messages wait for the next `flush()`.
176
158
 
177
159
  ### `render(element: RemixElement): Promise<RenderedEmail>`
178
160
 
179
- Renders a body tree to `{ html, text }`, deriving the text part from that same HTML. A
180
- whole document is given an XHTML 1.0 Transitional doctype and a fragment is left alone,
181
- since Outlook hands the document to Word, which drops into a quirks mode that collapses
182
- table cell heights without it.
161
+ Renders a body tree to `{ html, text }`, deriving `text` from the HTML. A whole document gets
162
+ an XHTML 1.0 Transitional doctype, which keeps Outlook out of quirks mode.
183
163
 
184
164
  ### `buildMimeMessage(message: NormalizedMessage): string`
185
165
 
186
- Assembles a normalized message into a raw RFC 5322 message, for a transport whose
187
- provider takes MIME instead of structured fields. It ships from the root because it is
188
- plain string assembly with no platform import; see
189
- [MIME Guarantees](#mime-guarantees) for what it produces.
166
+ Assembles a raw RFC 5322 message for a provider that takes MIME. Bodies go into
167
+ `multipart/alternative` ordered text, HTML, calendar (a single part ships without a boundary);
168
+ headers fold at 78 characters, non-ASCII names and subjects become RFC 2047 encoded words, each
169
+ part is quoted-printable or base64, whichever is smaller, line endings are CRLF, and `Bcc` is
170
+ left to the envelope.
190
171
 
191
172
  ### `isEmail(value: Message | Email): value is Email`
192
173
 
193
- Reports whether a value is an `Email` rather than a plain `Message`. Discrimination is
194
- structural: a callable `body` is the one member only an email has.
174
+ `true` when the value has a callable `body`, the one member only an `Email` has.
195
175
 
196
176
  ### `formatAddress(address: Address): string`
197
177
 
198
- Formats an address as an RFC 5322 mailbox — `user@example.com` without a display name,
199
- `Name <user@example.com>` with one — quoting the name only when it holds characters that
200
- would change how the mailbox parses.
178
+ An RFC 5322 mailbox: `user@example.com`, or `Name <user@example.com>` with the name quoted when
179
+ it needs to be.
201
180
 
202
181
  ### `toAddressList(value: Address | Address[] | undefined): Address[]`
203
182
 
204
- Coerces the single-or-list shape callers write into the list shape transports read,
205
- treating a missing value as no recipients. Returns a new array, so later mutation of the
206
- caller's array cannot leak into a message.
183
+ A new array from the single-or-list shape callers write; `undefined` becomes `[]`.
207
184
 
208
185
  ### `htmlToText(html: string): string`
209
186
 
210
- Derives the plain-text alternative from rendered HTML: link targets survive as
211
- `label (href)`, an image becomes its alt text, lists number or bullet their items, blocks
212
- become blank lines, and table rows become single lines. Anything marked
213
- `data-skip-in-text` is dropped, which is how an element says it belongs to the HTML part
214
- alone. `render()` applies this for you.
187
+ The plain-text alternative of rendered HTML: links become `label (href)`, images their alt
188
+ text, lists keep numbers or bullets, and table rows become lines. Elements marked
189
+ `data-skip-in-text` are dropped.
215
190
 
216
191
  ### `MailError`
217
192
 
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.
220
-
221
- ### `Email` (layout kit)
222
-
223
- Unbranded components for email bodies. Every rule is an inline style on a table, which is
224
- the only layout mail clients agree on, and every color is a prop so the kit ships no
225
- product identity.
226
-
227
- - **`Email.Layout`** — the full HTML document, wrapping the body in a centered card. It
228
- renders the only `<head>` and the only `<style>`, which is why web fonts and the dark
229
- stylesheet are its props. Props: `children?`, `preview?` (inbox preheader, hidden in
230
- the body), `logo?`, `title?`, `lang?`, `background?`, `surface?`, `color?`,
231
- `fontFamily?`, `fonts?`, `width?`, `darkStyles?`.
232
- - **`Email.Heading`** — Props: `children?`, `level?` (`1 | 2 | 3`), `color?`, `align?`.
233
- - **`Email.Text`** — Props: `children?`, `color?`, `muted?`, `size?`, `align?`.
234
- - **`Email.Link`** — an inline link opening in a new tab, inheriting the color around it
235
- so the underline carries the meaning. Props: `href`, `children?`, `color?`,
236
- `underline?`.
237
- - **`Email.CodeInline`** — a short run of code inside a sentence, sized in `em`. Props:
238
- `children?`.
239
- - **`Email.Section`** — a full-width band as a single-cell table, padding on the cell and
240
- everything else on the table. Props: `children?`, `padding?`, `background?`, `align?`.
241
- - **`Email.Row`** — puts its columns side by side as one table row, which is what an
242
- email uses instead of flex or grid. It does not wrap, so keep the count low. Props:
243
- `children?`, `gap?`.
244
- - **`Email.Column`** — one cell of a `Row`. A numeric width is written bare to the
245
- attribute and in pixels to the style, because Outlook reads the first and everything
246
- newer reads the second. Props: `children?`, `width?`, `align?`, `valign?`, `padding?`.
247
- - **`Email.Button`** — a link in a single-cell table, padding on the cell, so it stays a
248
- button in Outlook, where Word supports neither `display:inline-block` nor padding on an
249
- inline element. Props: `href`, `children?`, `background?`, `color?`, `radius?`.
250
- - **`Email.Table`** — a set of facts as label/value rows. Props: `rows`
251
- (`EmailTableRow[]`), `borderColor?`.
252
- - **`Email.Img`** — an image with the resets an inbox needs. `alt` is required because
253
- most readers see it: every major client blocks remote images until asked. Props: `src`,
254
- `alt`, `width?`, `height?`, `radius?`, `gap?`.
255
- - **`Email.Hr`** — a rule drawn as a top border rather than the native element, which
256
- several clients render as an inset two-tone groove. Props: `color?`, `gap?`.
257
- - **`Email.Footer`** — de-emphasized content under a hairline. Props: `children?`,
258
- `color?`, `borderColor?`.
193
+ The error of every failed send, with the provider or render error kept as `cause`.
259
194
 
260
- ### Types
195
+ ### `Email` layout kit
261
196
 
262
- `Result` below comes from `@sdxc/result` and `RemixElement` from `remix/component`.
197
+ Unbranded components laid out with inline-styled tables, every color a prop.
263
198
 
264
- ```typescript
265
- interface Address {
266
- email: string;
267
- name?: string;
268
- }
269
-
270
- interface Message {
271
- from?: Address;
272
- to: Address | Address[];
273
- cc?: Address | Address[];
274
- bcc?: Address | Address[];
275
- replyTo?: Address | Address[];
276
- subject: string;
277
- html?: string;
278
- text?: string;
279
- headers?: Record<string, string>;
280
- /** Explicit values; omitted means "now" and a generated id. Set both to keep tests deterministic. */
281
- date?: Date;
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
- }
199
+ | Component | Props |
200
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
201
+ | `Email.Layout` | `preview?`, `logo?`, `title?`, `lang?`, `background?`, `surface?`, `color?`, `fontFamily?`, `fonts?`, `width?`, `darkStyles?`, `children?` |
202
+ | `Email.Heading` | `level?` (`1 \| 2 \| 3`), `color?`, `align?`, `children?` |
203
+ | `Email.Text` | `color?`, `muted?`, `size?`, `align?`, `children?` |
204
+ | `Email.Link` | `href`, `color?`, `underline?`, `children?` |
205
+ | `Email.CodeInline` | `children?` |
206
+ | `Email.Section` | `padding?`, `background?`, `align?`, `children?` |
207
+ | `Email.Row` | `gap?`, `children?` (`Email.Column`s side by side, no wrapping) |
208
+ | `Email.Column` | `width?`, `align?`, `valign?`, `padding?`, `children?` |
209
+ | `Email.Button` | `href`, `background?`, `color?`, `radius?`, `children?` |
210
+ | `Email.Table` | `rows` (`EmailTableRow[]`), `borderColor?` |
211
+ | `Email.Img` | `src`, `alt`, `width?`, `height?`, `radius?`, `gap?` |
212
+ | `Email.Hr` | `color?`, `gap?` |
213
+ | `Email.Footer` | `color?`, `borderColor?`, `children?` |
289
214
 
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
- }
215
+ `Email.Layout` renders the document, its only `<head>` and `<style>`, the hidden `preview`
216
+ preheader, and a dark-mode stylesheet. `Email.MONO_FAMILY` and `Email.CODE_COLOR` are the font
217
+ stack and fill the code components use.
298
218
 
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;
312
- }
313
-
314
- interface Email {
315
- readonly to: Address | Address[];
316
- readonly subject: string;
317
- body(): RemixElement;
318
- readonly replyTo?: Address | Address[];
319
- readonly headers?: Record<string, string>;
320
- readonly unsubscribe?: Unsubscribe;
321
- readonly list?: MailingList;
322
- readonly calendar?: CalendarPart;
323
- }
324
-
325
- interface Transport {
326
- send(message: NormalizedMessage): Promise<Result<SentMessage, MailError>>;
327
- }
328
-
329
- interface SentMessage {
330
- /** Provider identifier when it returns one, otherwise the message's own `Message-ID`. */
331
- messageId: string;
332
- }
333
- ```
219
+ ### Types
334
220
 
335
- `NormalizedMessage` is what a transport receives: every field of `Message` with defaults
336
- applied, address fields as lists, `date` and `messageId` always present, a derived `text`
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
339
- transports ignore and tests use to identify a send by type. `RenderedEmail` is
340
- `{ html, text }`, `SendOptions` is `Partial<Message>`, `MailerOptions` is the
341
- constructor's options object, `EmailTableRow` is one `{ label, value }` row of an
342
- `Email.Table`, and `EmailFont` is one web font for `Email.Layout` to declare: `family`, a
343
- required `fallback` stack, and optional `src`, `weight` and `style`.
221
+ - `Message`: what a caller writes: `to`, `subject`, `html` and/or `text`, and optionally `from`,
222
+ `cc`, `bcc`, `replyTo`, `headers`, `date`, `messageId`, `unsubscribe`, `list` and `calendar`.
223
+ Address fields take an `Address` or a list.
224
+ - `Email`: an email class: `to`, `subject` and `body(): RemixElement`, plus optional `replyTo`,
225
+ `headers`, `unsubscribe`, `list` and `calendar`.
226
+ - `NormalizedMessage`: what a transport receives: address fields as lists, `date` and
227
+ `messageId` always set, the derived `text`, the generated `List-*` headers, `unsubscribe` and
228
+ `list` as the option or `null`, and `email` holding the source `Email` when there was one.
229
+ - `Transport`: `{ send(message: NormalizedMessage): Promise<Result<SentMessage, MailError>> }`.
230
+ - `SentMessage`: `{ messageId }`, the provider's id or the message's own `Message-ID`.
231
+ - `Address`: `{ email, name? }`.
232
+ - `Unsubscribe`: `{ url, mailto?, oneClick? }`; `url` must be `https:`, `mailto` is an address
233
+ or a `mailto:` URI, and `oneClick` (default `true`) writes `List-Unsubscribe-Post`.
234
+ - `MailingList`: `{ id, name? }`, written as `List-Id`.
235
+ - `CalendarPart`: `{ method, content, filename? }`, an iCalendar object whose `method` matches
236
+ its `METHOD`; `filename` also attaches it as a file.
237
+ - `MailerOptions`, `SendOptions` (`Partial<Message>`), `RenderedEmail` (`{ html, text }`),
238
+ `EmailTableRow` (`{ label, value }`) and `EmailFont` (`family`, `fallback`, and optional
239
+ `src: { url, format }`, `weight` and `style`).
344
240
 
345
241
  ### `@sdxc/mail/markdown`
346
242
 
347
243
  #### `Markdown`
348
244
 
349
- Renders a parsed document through the layout kit. Props: `document`. Parsing stays with
350
- the caller, so one document renders in an inbox and on a page, and mail that carries no
351
- markdown pays for no parser.
352
-
353
- The mapping is lossy where an inbox cannot lay out what markdown expresses: headings stop
354
- at level three, a table becomes a real `<table>`, an alert becomes a labelled block quote,
355
- and a footnote's body becomes a labelled block under the prose pointing at it. Raw HTML,
356
- block and inline alike, arrives as escaped text, which keeps a document from any source
357
- safe to send.
245
+ Renders a parsed `@sdxc/markdown` document (`document` prop) through the layout kit. Headings
246
+ stop at level three, alerts become labelled quotes, footnotes become labelled blocks, and raw
247
+ HTML arrives as escaped text.
358
248
 
359
249
  #### `CodeBlock`
360
250
 
361
- A fenced block of code, highlighted, inside a single-cell table — Outlook paints a `<pre>`
362
- with a background to the width of its text rather than the column. Props: `code`,
363
- `language?`, `tokens?`. A painted document carries its runs on the code node and
364
- `Markdown` passes them through as `tokens`; given `code` alone the block tokenizes itself,
365
- and an unknown language renders unpainted.
251
+ A highlighted code block in a single-cell table. Props: `code`, `language?`, `tokens?`; without
252
+ `tokens` it highlights itself, and an unknown language renders unpainted.
366
253
 
367
254
  ### `@sdxc/mail/memory`
368
255
 
369
256
  #### `new MemoryTransport(options?: MemoryTransportOptions)`
370
257
 
371
- Records every delivery instead of sending it, so tests assert on real behavior rather than
372
- on a mocked SDK module. `options.mime` also records the assembled raw message; it is off
373
- by default because most tests read the normalized one.
374
-
375
- - **`transport.messages`** — every recorded normalized message, oldest first
376
- - **`transport.last`** — the most recent message, or `undefined`
377
- - **`transport.deliveries`** — every delivery as `{ message, mime? }`, oldest first
378
- - **`transport.lastMime`** — the raw MIME of the most recent delivery, or `undefined`
379
- - **`transport.find(predicate)`** — the first message matching a predicate
380
- - **`transport.clear()`** — forgets every delivery, so one instance serves several tests
258
+ Records every delivery instead of sending it. `options.mime: true` also records the raw
259
+ message. Members: `messages`, `last`, `deliveries` (`RecordedDelivery[]`, each
260
+ `{ message, mime? }`), `lastMime`, `find(predicate)` and `clear()`.
381
261
 
382
262
  ### `@sdxc/mail/cloudflare`
383
263
 
384
264
  #### `new CloudflareTransport(binding: SendEmailBinding)`
385
265
 
386
- Delivers through the Workers email sending binding, which composes the message from
387
- structured fields, so the transport assembles no MIME and the app imports no platform
388
- class. Success carries the identifier the platform assigned; a refusal arrives as a
389
- `MailError` carrying the platform's rejection as `cause`.
390
-
391
- The binding writes its own `Date` and `Message-ID`, so the values a normalized message
392
- carries stay local, and it takes one `replyTo` mailbox rather than a list, so the first is
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
397
- exported as the seam those platform assumptions live behind, declared locally so this
398
- package typechecks outside a Workers project. See the
399
- [Cloudflare email routing docs](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/)
400
- for declaring the binding and verifying a sending domain.
266
+ Delivers through the Workers
267
+ [`send_email` binding](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/).
268
+ The binding writes its own `Date` and `Message-ID`, ships the first `replyTo` only, and receives
269
+ a `calendar` as a `text/calendar` attachment named by `filename` or `invite.ics`.
270
+ `SendEmailBinding`, `SendEmailMessage`, `SendEmailAttachment` and `SendEmailResult` type the
271
+ binding.
401
272
 
402
273
  ### `@sdxc/mail/middleware`
403
274
 
404
- #### `mail(options: MailMiddlewareOptions): Middleware`
275
+ #### `mail(options: MailMiddlewareOptions): Middleware` (default export)
405
276
 
406
- Publishes a request-scoped `Mailer` as `context.email` and flushes its deferred queue once
407
- `next()` resolves. `options` carries `transport` — a `Transport`, or a
408
- `(context) => Transport` factory when it is resolved per request — plus `from`, and
409
- optionally `replyTo` and `headers`.
410
-
411
- A delivered message sets `mail.sent` on the invocation's log and notes the provider's
412
- message id; a failed one is a `mail.send_failed` warning, since the response is already
413
- out. With no log current the outcomes are dropped. The module augments `RequestContext`,
414
- so `context.email` is typed wherever the middleware is imported. Despite the name, it is
415
- the object that _sends_ mail, not the current user's address.
277
+ Publishes a request-scoped `Mailer` as `context.email` and flushes its queue after `next()`.
278
+ `options` takes `transport` (a `Transport`, or `(context) => Transport` per request), `from`,
279
+ `replyTo?` and `headers?`. Each deferred send logs `mail.sent` or a `mail.send_failed` warning
280
+ on the current log.
416
281
 
417
282
  ### `@sdxc/mail/unsubscribe`
418
283
 
419
- #### `signUnsubscribeToken(secret, claims, options?): Promise<Result<string, MailError>>`
284
+ #### `signUnsubscribeToken(secret: string, claims: { subject: string; list: string }, options?: UnsubscribeToken.SignOptions): Promise<Result<string, MailError>>`
420
285
 
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.
286
+ A URL-safe token: hex HMAC-SHA-256 then the base64url payload. `subject` is an opaque id (an
287
+ email address is refused) and `list` holds no colon. `options.expiresAt` sets an expiry;
288
+ `options.purpose` (default `"unsubscribe:v1:"`) separates this secret's use from others.
427
289
 
428
- #### `verifyUnsubscribeToken(secret, token, options?): Promise<Result<UnsubscribeToken.Claims, InvalidUnsubscribeTokenError>>`
290
+ #### `verifyUnsubscribeToken(secret: string, token: string, options?: UnsubscribeToken.Options): Promise<Result<UnsubscribeToken.Claims, InvalidUnsubscribeTokenError>>`
429
291
 
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.
292
+ Checks the MAC in constant time and answers `{ subject, list, issuedAt }`. Every bad token
293
+ (malformed, tampered, other secret or purpose, expired) fails with the same
294
+ `InvalidUnsubscribeTokenError`. `issuedAt` is `null` for a payload holding only `list:subject`.
435
295
 
436
296
  #### `isOneClickUnsubscribe(form: FormData): boolean`
437
297
 
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
298
+ `true` for RFC 8058's `List-Unsubscribe=One-Click` body, telling the provider's POST apart from
299
+ a person submitting a confirmation form.
443
300
 
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.
301
+ ## Pattern: Choosing Between `send()` And `later()`
450
302
 
451
- An optional email declares its target and list; the mailer writes the headers:
303
+ Await `send()` when the response depends on the outcome; queue with `later()` when a failed
304
+ send must not fail the request.
452
305
 
453
306
  ```typescript
454
- import type { Email, MailingList, Unsubscribe } from "@sdxc/mail";
307
+ import { isFailure } from "@sdxc/result";
308
+
309
+ router.post("/password-reset", async (context) => {
310
+ let result = await context.email.send(new PasswordResetEmail(user));
311
+ if (isFailure(result)) return new Response("Could not send the email", { status: 502 });
312
+ return new Response(null, { status: 204 });
313
+ });
314
+
315
+ router.post("/invites", async (context) => {
316
+ context.email.later(new TeamInviteEmail(invite)); // sent after the response, failures logged
317
+ return new Response(null, { status: 204 });
318
+ });
319
+ ```
320
+
321
+ Outside the middleware, call `mailer.flush()` yourself, for example at the end of a job.
322
+
323
+ ## Pattern: One-Click Unsubscribe For Bulk Mail
324
+
325
+ Gmail and Yahoo require one-click unsubscribe on subscribed and marketing mail. An email declares
326
+ its target and list, and the mailer writes the headers:
327
+
328
+ ```tsx
329
+ import type { MailingList, Unsubscribe } from "@sdxc/mail";
330
+
331
+ import { Email } from "@sdxc/mail";
332
+ import { signUnsubscribeToken } from "@sdxc/mail/unsubscribe";
333
+ import { unwrap } from "@sdxc/result";
455
334
 
456
335
  class DailyDigestEmail implements Email {
457
- // to, subject, body() ...
336
+ constructor(
337
+ private member: { id: string; email: string },
338
+ private token: string,
339
+ ) {}
340
+
341
+ get to() {
342
+ return { email: this.member.email };
343
+ }
344
+
345
+ get subject() {
346
+ return "Your daily digest";
347
+ }
458
348
 
459
349
  get unsubscribe(): Unsubscribe {
460
- return { url: this.digest.unsubscribeUrl, mailto: "unsubscribe@example.com" };
350
+ return {
351
+ url: `https://example.com/unsubscribe/${this.token}`,
352
+ mailto: "unsubscribe@example.com",
353
+ };
461
354
  }
462
355
 
463
356
  get list(): MailingList {
464
357
  return { id: "daily-digest.example.com", name: "Daily digest" };
465
358
  }
359
+
360
+ body() {
361
+ return (
362
+ <Email.Layout title="Daily digest">
363
+ <Email.Text>Here is what happened today.</Email.Text>
364
+ </Email.Layout>
365
+ );
366
+ }
466
367
  }
368
+
369
+ let token = unwrap(await signUnsubscribeToken(secret, { subject: member.id, list: "digest" }));
370
+ await mailer.send(new DailyDigestEmail(member, token));
467
371
  ```
468
372
 
469
373
  ```text
@@ -472,37 +376,33 @@ List-Unsubscribe-Post: List-Unsubscribe=One-Click
472
376
  List-Id: Daily digest <daily-digest.example.com>
473
377
  ```
474
378
 
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:
379
+ The provider POSTs to the URL with no cookies, so the endpoint sits outside any auth guard and
380
+ trusts only the token. `GET` shows a confirmation and changes nothing, since link scanners
381
+ follow every URL:
484
382
 
485
383
  ```typescript
486
384
  import { isOneClickUnsubscribe, verifyUnsubscribeToken } from "@sdxc/mail/unsubscribe";
487
- import { isSuccess } from "@sdxc/result";
385
+ import { isFailure } from "@sdxc/result";
386
+
387
+ router.post("/unsubscribe/:token", async (context) => {
388
+ let claims = await verifyUnsubscribeToken(secret, context.params.token);
389
+ if (isFailure(claims)) return new Response("Link expired", { status: 400 });
488
390
 
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();
391
+ await removeSubscription(claims.data.subject, claims.data.list);
392
+
393
+ let form = await context.request.formData();
394
+ if (isOneClickUnsubscribe(form)) return new Response(null, { status: 200 });
395
+ return new Response("You are unsubscribed.");
396
+ });
493
397
  ```
494
398
 
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`.
399
+ A send fails with a `MailError` when the URL is not `https:` or a `List-*` header is also set by
400
+ hand. Rotating the secret invalidates every delivered link.
499
401
 
500
- ## Calendar Invitations
402
+ ## Pattern: Calendar Invitations
501
403
 
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:
404
+ A message or email carrying `calendar` reaches Gmail, Outlook and Apple Mail as an invitation
405
+ with accept and decline buttons. Write the content with any iCalendar library:
506
406
 
507
407
  ```typescript
508
408
  await mailer.send({
@@ -513,10 +413,6 @@ await mailer.send({
513
413
  });
514
414
  ```
515
415
 
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
416
  ```text
521
417
  multipart/mixed
522
418
  ├── multipart/alternative
@@ -526,107 +422,11 @@ multipart/mixed
526
422
  └── application/ics; name="invite.ics" (attachment)
527
423
  ```
528
424
 
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
-
533
- ## MIME Guarantees
534
-
535
- What `buildMimeMessage` produces, for a transport that speaks raw messages:
536
-
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`.
541
- - **Headers** — `From`, `To`, `Cc`, `Reply-To`, `Subject`, `Date`, `Message-ID`,
542
- `MIME-Version`, then custom headers, then the `Content-*` headers. `Bcc` is absent,
543
- since those recipients are addressed by the envelope, and a custom header repeating a
544
- derived name is dropped rather than emitted twice.
545
- - **Folding** — a header past 78 characters folds at an existing space and the
546
- continuation keeps that space, so unfolding restores the value character for character.
547
- A longer run with no space in it is left long, since folding inside a token corrupts it.
548
- - **Encoded words** — a non-ASCII subject or display name becomes base64 RFC 2047 encoded
549
- words, chunked on character boundaries and sized so the line still fits the limit. A
550
- display name is encoded rather than quoted, because a quoted encoded word reaches the
551
- reader literally.
552
- - **Part encoding** — quoted-printable while text stays mostly ASCII, keeping the raw
553
- message readable, and base64 once escaping would inflate the body more than base64 does.
554
- - **Boundaries** — the boundary carries a random UUID and is checked against the encoded
555
- bodies; base64's alphabet has no `-` and quoted-printable escapes a leading one, so no
556
- body line reads as a delimiter.
557
- - **Line endings** — every break is CRLF, whatever the caller's bodies used, and both
558
- encodings wrap at 76 characters.
559
-
560
- ## Dark Mode
561
-
562
- `Email.Layout` declares `color-scheme: light dark` and ships the dark half of it, because
563
- declaring one without shipping it is worse than declaring nothing: Apple Mail reads the
564
- declaration as a promise the message paints its own dark mode and stops remapping colors.
565
-
566
- Every element therefore carries two things. The inline style is the light baseline, kept
567
- by clients that strip `<style>`. A class — `mail-page`, `mail-surface`, `mail-text`,
568
- `mail-muted`, `mail-rule`, `mail-action`, `mail-action-label` — is what the layout's
569
- `prefers-color-scheme: dark` block overrides with `!important`. A class is emitted only
570
- where the caller left that color to the kit, so passing `color` to a `Heading` opts it out
571
- of the dark rule.
572
-
573
- Components of your own that paint inside the card pass their rules as `darkStyles`,
574
- appended inside that same media query:
575
-
576
- ```tsx
577
- <Email.Layout darkStyles=".status-down{color:#f87171 !important;}">
578
- ```
579
-
580
- That stylesheet is a text node and therefore escaped, so CSS passed here uses descendant
581
- and class selectors rather than `>` or `&`.
582
-
583
- ## Pattern: Deciding Between `send()` And `later()`
584
-
585
- `send()` when the outcome must be known before responding, so the caller branches on the
586
- `Result` and changes the response:
587
-
588
- ```typescript
589
- import { isFailure } from "@sdxc/result";
590
-
591
- let result = await mailer.send(new PasswordResetEmail(user));
592
- if (isFailure(result)) return new Response("Could not send the reset email", { status: 502 });
593
- ```
594
-
595
- `later()` when a failed send must not fail the operation. Queued messages flush after the
596
- response is produced, so they cannot influence it, and their failures are logged:
597
-
598
- ```typescript
599
- context.email.later(new TeamInviteEmail(invite));
600
- return new Response(null, { status: 204 });
601
- ```
602
-
603
- That gives a fire-and-forget send a defined lifetime, which an unawaited promise does not
604
- have.
605
-
606
- ## Pattern: Translating A Subject For The Recipient
607
-
608
- A subject belongs in the _reader's_ language, which is not always the request language: an
609
- invite is composed by one person and read by another. The package resolves no locales — a
610
- subject reaches it as a string the application already translated — so the application
611
- picks the language and hands the translator to the constructor:
612
-
613
- ```typescript
614
- let translate = await translatorFor(invite.locale);
615
- let subject = translate("invite.subject", { team: invite.team });
616
-
617
- await mailer.send(new TeamInviteEmail({ ...invite, subject }));
618
- ```
619
-
620
- Choose that locale in this order: the recipient's stored preference, the locale recorded
621
- on the record being acted on, then the app's fallback. The requester's own locale is the
622
- last resort, since the common mistake is sending in the sender's language rather than the
623
- reader's.
425
+ Keep a body beside the calendar for clients that show no invitation; `filename` adds the
426
+ attachment for clients that only offer files.
624
427
 
625
428
  ## Pattern: Rendering Markdown As A Body
626
429
 
627
- A document parsed elsewhere renders into the same card as a hand-authored body, with its
628
- code fences painted:
629
-
630
430
  ```tsx
631
431
  import { highlight } from "@sdxc/highlight/markdown";
632
432
  import { Email, render } from "@sdxc/mail";
@@ -646,26 +446,87 @@ let { html, text } = await render(
646
446
  await mailer.send({ to: { email: "user@example.com" }, subject: "Release notes", html, text });
647
447
  ```
648
448
 
649
- Highlighting is optional: a document handed over unpainted still renders, with each block
650
- tokenized as it is drawn.
449
+ Highlighting is optional: an unpainted document still renders, each code block highlighting
450
+ itself.
651
451
 
652
- ## Pattern: Switching Providers
452
+ ## Pattern: Brand Fonts And Dark Mode
653
453
 
654
- Sender configuration is a plain object and the transport is the only provider-aware piece,
655
- so a switch is one construction site:
454
+ `Email.Layout` declares `color-scheme: light dark` and paints its own dark variant. Inline styles
455
+ are the light baseline; the kit's classes (`mail-page`, `mail-surface`, `mail-text`,
456
+ `mail-muted`, `mail-rule`, `mail-action`, `mail-action-label`) carry the dark overrides, and only
457
+ where a color was left to the kit. Your own components add rules through `darkStyles`:
458
+
459
+ ```tsx
460
+ import { Email } from "@sdxc/mail";
461
+
462
+ <Email.Layout
463
+ fonts={[
464
+ {
465
+ family: "Inter",
466
+ fallback: "Helvetica, Arial, sans-serif",
467
+ src: { url: "https://example.com/inter.woff2", format: "woff2" },
468
+ weight: 400,
469
+ },
470
+ ]}
471
+ fontFamily="Inter, Helvetica, Arial, sans-serif"
472
+ darkStyles=".status-down{color:#f87171 !important;}"
473
+ >
474
+ <Email.Text>
475
+ Status: <span class="status-down">down</span>
476
+ </Email.Text>
477
+ <Email.Row gap={16}>
478
+ <Email.Column width={120}>
479
+ <Email.Img src="https://example.com/chart.png" alt="Uptime chart" width={120} />
480
+ </Email.Column>
481
+ <Email.Column>
482
+ <Email.Table rows={[{ label: "Since", value: "02:14 UTC" }]} />
483
+ </Email.Column>
484
+ </Email.Row>
485
+ </Email.Layout>;
486
+ ```
487
+
488
+ `fallback` is the font most readers see, since Gmail, Yahoo and Outlook ignore `@font-face`.
489
+ `darkStyles` is escaped as text, so use descendant and class selectors, not `>` or `&`.
490
+
491
+ ## Pattern: A Transport For Another Provider
492
+
493
+ A transport turns a `NormalizedMessage` into one provider call. For a provider that takes raw
494
+ MIME, `buildMimeMessage` does the assembly:
656
495
 
657
496
  ```typescript
497
+ import type { NormalizedMessage, SentMessage, Transport } from "@sdxc/mail";
498
+ import type { Result } from "@sdxc/result";
499
+
500
+ import { buildMimeMessage, MailError, Mailer } from "@sdxc/mail";
501
+ import { failure, success } from "@sdxc/result";
502
+
503
+ class RawMimeTransport implements Transport {
504
+ constructor(
505
+ private endpoint: string,
506
+ private apiKey: string,
507
+ ) {}
508
+
509
+ async send(message: NormalizedMessage): Promise<Result<SentMessage, MailError>> {
510
+ let response = await fetch(this.endpoint, {
511
+ method: "POST",
512
+ headers: { authorization: `Bearer ${this.apiKey}`, "content-type": "message/rfc822" },
513
+ body: buildMimeMessage(message),
514
+ });
515
+ if (!response.ok) {
516
+ return failure(new MailError("Provider rejected the message", { cause: response.status }));
517
+ }
518
+ return success({ messageId: message.messageId });
519
+ }
520
+ }
521
+
658
522
  let mailer = new Mailer({
659
- transport: new CloudflareTransport(env.EMAIL), // the only line that changes
523
+ transport: new RawMimeTransport("https://mail.example.com/send", apiKey),
660
524
  from: { email: "no-reply@example.com", name: "Example" },
661
- replyTo: { email: "hello@example.com" },
662
525
  });
663
526
  ```
664
527
 
665
- Emails, services and tests are untouched, which also makes a switch reversible. Providers
666
- differ in what they carry, though: check any field a new transport is quiet about, and
667
- verify the first deliveries for real, since headers, both body parts and spam placement
668
- are only observable end to end.
528
+ Emails, handlers and tests stay the same when the transport changes; verify the first real
529
+ deliveries, since headers and spam placement are observable only end to end.
669
530
 
670
531
  ## Versioning
671
532
 
package/dist/mailer.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * @copyright Sergio Xalambrí 2026
10
10
  */
11
11
  import { failure, isFailure, success, wrap } from "@sdxc/result";
12
+ import { generateUUID } from "@sdxc/uuid/v4";
12
13
  import { MailError } from "./errors.js";
13
14
  import { isValidAddress, toAddressList } from "./lib/address.js";
14
15
  import { htmlToText } from "./lib/html-to-text.js";
@@ -55,7 +56,7 @@ function derivePlainText(message, overrides) {
55
56
  */
56
57
  function generateMessageId(from) {
57
58
  let domain = from.email.split("@").at(1) ?? "localhost";
58
- return `<${crypto.randomUUID()}@${domain}>`;
59
+ return `<${generateUUID()}@${domain}>`;
59
60
  }
60
61
  /**
61
62
  * Rejects messages no transport could deliver, so the failure names the mistake instead of
package/dist/mime.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * @author [Sergio Xalambrí](https://sergiodxa.com)
9
9
  * @copyright Sergio Xalambrí 2026
10
10
  */
11
+ import { generateUUID } from "@sdxc/uuid/v4";
11
12
  import { formatAddress } from "./lib/address.js";
12
13
  /** Line ending every part of a MIME message uses, regardless of the host platform. */
13
14
  const CRLF = "\r\n";
@@ -294,9 +295,9 @@ function encodePart(contentType, content) {
294
295
  * quoted-printable escapes a leading one), checked directly against the bodies too.
295
296
  */
296
297
  function selectBoundary(parts) {
297
- let boundary = `${BOUNDARY_PREFIX}${crypto.randomUUID()}`;
298
+ let boundary = `${BOUNDARY_PREFIX}${generateUUID()}`;
298
299
  while (parts.some((part) => part.body.includes(boundary))) {
299
- boundary = `${BOUNDARY_PREFIX}${crypto.randomUUID()}`;
300
+ boundary = `${BOUNDARY_PREFIX}${generateUUID()}`;
300
301
  }
301
302
  return boundary;
302
303
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/mail",
3
- "version": "2026.10.10",
3
+ "version": "2026.10.13",
4
4
  "description": "Transport-agnostic transactional email with pluggable transports and remix/component rendering",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,14 +13,15 @@
13
13
  "./unsubscribe": "./dist/unsubscribe.js"
14
14
  },
15
15
  "dependencies": {
16
- "@sdxc/crypto": "2026.10.10",
17
- "@sdxc/highlight": "2026.10.10",
18
- "@sdxc/logger": "2026.10.10",
19
- "@sdxc/markdown": "2026.10.10",
20
- "@sdxc/result": "2026.10.6",
16
+ "@sdxc/crypto": "2026.10.13",
17
+ "@sdxc/highlight": "2026.10.13",
18
+ "@sdxc/logger": "2026.10.13",
19
+ "@sdxc/markdown": "2026.10.13",
20
+ "@sdxc/result": "2026.10.13",
21
+ "@sdxc/uuid": "2026.10.13",
21
22
  "remix": "3.0.0"
22
23
  },
23
- "gitHead": "b61f19c59866708a2d89a5a8f4655ebbc16941b0",
24
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
24
25
  "publishConfig": {
25
26
  "access": "public"
26
27
  },