@sdxc/mail 0.0.0-pre.1

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Xalambrí
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,544 @@
1
+ # @sdxc/mail
2
+
3
+ Transport-agnostic transactional email: pluggable transports, one mailer that normalizes
4
+ every message, and a `remix/ui` layout kit that renders both body parts.
5
+
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.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm add @sdxc/mail
14
+ ```
15
+
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/ui` 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. A transport ships from
29
+ its own subpath, so importing one never pulls another's platform dependency into a bundle.
30
+
31
+ ## Usage
32
+
33
+ ### Send A Message
34
+
35
+ ```typescript
36
+ import { Mailer } from "@sdxc/mail";
37
+ import { MemoryTransport } from "@sdxc/mail/memory";
38
+ import { isFailure } from "@sdxc/result";
39
+
40
+ let mailer = new Mailer({
41
+ transport: new MemoryTransport(),
42
+ from: { email: "no-reply@example.com", name: "Example" },
43
+ replyTo: { email: "hello@example.com" },
44
+ });
45
+
46
+ let result = await mailer.send({
47
+ to: { email: "user@example.com" },
48
+ subject: "You have been invited",
49
+ html: "<p>Welcome aboard.</p>",
50
+ });
51
+
52
+ if (isFailure(result)) console.warn(result.error.message);
53
+ ```
54
+
55
+ The plain-text part is derived from the HTML, so a message ships both parts without a
56
+ second authoring step.
57
+
58
+ ### Author An Email As A Class
59
+
60
+ ```tsx
61
+ import type { Email as EmailContract } from "@sdxc/mail";
62
+ import type { Handle } from "remix/ui";
63
+
64
+ import { Email } from "@sdxc/mail";
65
+
66
+ function InviteBody(handle: Handle<{ team: string; url: string }>) {
67
+ return () => {
68
+ let { team, url } = handle.props;
69
+
70
+ return (
71
+ <Email.Layout preview={`Join ${team}`} title="Invite">
72
+ <Email.Heading>You have been invited</Email.Heading>
73
+ <Email.Text>Join {team} to keep an eye on your services.</Email.Text>
74
+ <Email.Button href={url}>Accept invite</Email.Button>
75
+ <Email.Footer>You received this because someone invited you to a team.</Email.Footer>
76
+ </Email.Layout>
77
+ );
78
+ };
79
+ }
80
+
81
+ export class TeamInviteEmail implements EmailContract {
82
+ constructor(private invite: { team: string; email: string; url: string; subject: string }) {}
83
+
84
+ get to() {
85
+ return { email: this.invite.email };
86
+ }
87
+
88
+ get subject() {
89
+ return this.invite.subject;
90
+ }
91
+
92
+ body() {
93
+ return <InviteBody team={this.invite.team} url={this.invite.url} />;
94
+ }
95
+ }
96
+ ```
97
+
98
+ `Email` names both the contract in type space and the layout kit in value space, so
99
+ `implements Email` and `<Email.Layout>` come from one import. Hand the class straight to
100
+ `send()`: `await mailer.send(new TeamInviteEmail(invite))`.
101
+
102
+ ### Publish A Mailer On The Request Context
103
+
104
+ ```typescript
105
+ import { CloudflareTransport } from "@sdxc/mail/cloudflare";
106
+ import mail from "@sdxc/mail/middleware";
107
+ import { env } from "cloudflare:workers";
108
+ import { createRouter } from "remix/router";
109
+
110
+ let router = createRouter({
111
+ middleware: [
112
+ mail({
113
+ transport: new CloudflareTransport(env.EMAIL),
114
+ from: { email: "no-reply@example.com", name: "Example" },
115
+ }),
116
+ ],
117
+ });
118
+
119
+ router.post("/invites", async (context) => {
120
+ context.email.later(new TeamInviteEmail(invite));
121
+ return new Response(null, { status: 204 });
122
+ });
123
+ ```
124
+
125
+ `context.email` is a request-scoped `Mailer`. Whatever `later()` queued is flushed after
126
+ the response is produced, and each outcome lands on the invocation's log.
127
+
128
+ ### Test Without Mocking A Provider
129
+
130
+ ```typescript
131
+ import { Mailer } from "@sdxc/mail";
132
+ import { MemoryTransport } from "@sdxc/mail/memory";
133
+
134
+ let transport = new MemoryTransport();
135
+ let mailer = new Mailer({ transport, from: { email: "no-reply@example.com" } });
136
+
137
+ await mailer.send(new TeamInviteEmail(invite));
138
+
139
+ expect(transport.messages).toHaveLength(1);
140
+ expect(transport.last?.email).toBeInstanceOf(TeamInviteEmail);
141
+ expect(transport.last?.text).toContain(invite.url);
142
+ ```
143
+
144
+ Recorded messages are the normalized ones a provider would have received, so defaults,
145
+ coerced address lists and the derived text part are all visible.
146
+
147
+ ## API
148
+
149
+ ### `new Mailer(options: MailerOptions)`
150
+
151
+ Sends mail through a transport, applying one app's sender identity to every message.
152
+ `options` carries `transport`, `from`, and optionally `replyTo` and `headers` — the last
153
+ added to every message, with per-message headers winning.
154
+
155
+ ### `mailer.send(input, overrides?): Promise<Result<SentMessage, MailError>>`
156
+
157
+ Normalizes and delivers one message, awaiting the outcome. Never throws: a render
158
+ failure, an invalid message, a rejected delivery and a transport that throws all arrive as
159
+ a `MailError` failure. `input` is a plain `Message` or an `Email`; `overrides` replace
160
+ fields for this send only.
161
+
162
+ ### `mailer.later(input, overrides?): void`
163
+
164
+ Queues a message for the next `flush()`. Nothing is rendered or validated yet, which is
165
+ what gives a fire-and-forget send a defined lifetime.
166
+
167
+ ### `mailer.flush(): Promise<Result<SentMessage, MailError>[]>`
168
+
169
+ Sends everything `later()` queued and empties the queue, returning one result per message
170
+ in queue order. Never throws. The middleware calls this for you.
171
+
172
+ ### `mailer.pending: number`
173
+
174
+ How many messages wait for the next `flush()`.
175
+
176
+ ### `render(element: RemixElement): Promise<RenderedEmail>`
177
+
178
+ Renders a body tree to `{ html, text }`, deriving the text part from that same HTML. A
179
+ whole document is given an XHTML 1.0 Transitional doctype and a fragment is left alone,
180
+ since Outlook hands the document to Word, which drops into a quirks mode that collapses
181
+ table cell heights without it.
182
+
183
+ ### `buildMimeMessage(message: NormalizedMessage): string`
184
+
185
+ Assembles a normalized message into a raw RFC 5322 message, for a transport whose
186
+ provider takes MIME instead of structured fields. It ships from the root because it is
187
+ plain string assembly with no platform import; see
188
+ [MIME Guarantees](#mime-guarantees) for what it produces.
189
+
190
+ ### `isEmail(value: Message | Email): value is Email`
191
+
192
+ Reports whether a value is an `Email` rather than a plain `Message`. Discrimination is
193
+ structural: a callable `body` is the one member only an email has.
194
+
195
+ ### `formatAddress(address: Address): string`
196
+
197
+ Formats an address as an RFC 5322 mailbox — `user@example.com` without a display name,
198
+ `Name <user@example.com>` with one — quoting the name only when it holds characters that
199
+ would change how the mailbox parses.
200
+
201
+ ### `toAddressList(value: Address | Address[] | undefined): Address[]`
202
+
203
+ Coerces the single-or-list shape callers write into the list shape transports read,
204
+ treating a missing value as no recipients. Returns a new array, so later mutation of the
205
+ caller's array cannot leak into a message.
206
+
207
+ ### `htmlToText(html: string): string`
208
+
209
+ Derives the plain-text alternative from rendered HTML: link targets survive as
210
+ `label (href)`, an image becomes its alt text, lists number or bullet their items, blocks
211
+ become blank lines, and table rows become single lines. Anything marked
212
+ `data-skip-in-text` is dropped, which is how an element says it belongs to the HTML part
213
+ alone. `render()` applies this for you.
214
+
215
+ ### `MailError`
216
+
217
+ The single error type the package reports. The original provider or render error is kept
218
+ as `cause`, so one log line can name the root problem.
219
+
220
+ ### `Email` (layout kit)
221
+
222
+ Unbranded components for email bodies. Every rule is an inline style on a table, which is
223
+ the only layout mail clients agree on, and every color is a prop so the kit ships no
224
+ product identity.
225
+
226
+ - **`Email.Layout`** — the full HTML document, wrapping the body in a centered card. It
227
+ renders the only `<head>` and the only `<style>`, which is why web fonts and the dark
228
+ stylesheet are its props. Props: `children?`, `preview?` (inbox preheader, hidden in
229
+ the body), `logo?`, `title?`, `lang?`, `background?`, `surface?`, `color?`,
230
+ `fontFamily?`, `fonts?`, `width?`, `darkStyles?`.
231
+ - **`Email.Heading`** — Props: `children?`, `level?` (`1 | 2 | 3`), `color?`, `align?`.
232
+ - **`Email.Text`** — Props: `children?`, `color?`, `muted?`, `size?`, `align?`.
233
+ - **`Email.Link`** — an inline link opening in a new tab, inheriting the color around it
234
+ so the underline carries the meaning. Props: `href`, `children?`, `color?`,
235
+ `underline?`.
236
+ - **`Email.CodeInline`** — a short run of code inside a sentence, sized in `em`. Props:
237
+ `children?`.
238
+ - **`Email.Section`** — a full-width band as a single-cell table, padding on the cell and
239
+ everything else on the table. Props: `children?`, `padding?`, `background?`, `align?`.
240
+ - **`Email.Row`** — puts its columns side by side as one table row, which is what an
241
+ email uses instead of flex or grid. It does not wrap, so keep the count low. Props:
242
+ `children?`, `gap?`.
243
+ - **`Email.Column`** — one cell of a `Row`. A numeric width is written bare to the
244
+ attribute and in pixels to the style, because Outlook reads the first and everything
245
+ newer reads the second. Props: `children?`, `width?`, `align?`, `valign?`, `padding?`.
246
+ - **`Email.Button`** — a link in a single-cell table, padding on the cell, so it stays a
247
+ button in Outlook, where Word supports neither `display:inline-block` nor padding on an
248
+ inline element. Props: `href`, `children?`, `background?`, `color?`, `radius?`.
249
+ - **`Email.Table`** — a set of facts as label/value rows. Props: `rows`
250
+ (`EmailTableRow[]`), `borderColor?`.
251
+ - **`Email.Img`** — an image with the resets an inbox needs. `alt` is required because
252
+ most readers see it: every major client blocks remote images until asked. Props: `src`,
253
+ `alt`, `width?`, `height?`, `radius?`, `gap?`.
254
+ - **`Email.Hr`** — a rule drawn as a top border rather than the native element, which
255
+ several clients render as an inset two-tone groove. Props: `color?`, `gap?`.
256
+ - **`Email.Footer`** — de-emphasized content under a hairline. Props: `children?`,
257
+ `color?`, `borderColor?`.
258
+
259
+ ### Types
260
+
261
+ `Result` below comes from `@sdxc/result` and `RemixElement` from `remix/ui`.
262
+
263
+ ```typescript
264
+ interface Address {
265
+ email: string;
266
+ name?: string;
267
+ }
268
+
269
+ interface Message {
270
+ from?: Address;
271
+ to: Address | Address[];
272
+ cc?: Address | Address[];
273
+ bcc?: Address | Address[];
274
+ replyTo?: Address | Address[];
275
+ subject: string;
276
+ html?: string;
277
+ text?: string;
278
+ headers?: Record<string, string>;
279
+ /** Explicit values; omitted means "now" and a generated id. Set both to keep tests deterministic. */
280
+ date?: Date;
281
+ messageId?: string;
282
+ }
283
+
284
+ interface Email {
285
+ readonly to: Address | Address[];
286
+ readonly subject: string;
287
+ body(): RemixElement;
288
+ readonly replyTo?: Address | Address[];
289
+ readonly headers?: Record<string, string>;
290
+ }
291
+
292
+ interface Transport {
293
+ send(message: NormalizedMessage): Promise<Result<SentMessage, MailError>>;
294
+ }
295
+
296
+ interface SentMessage {
297
+ /** Provider identifier when it returns one, otherwise the message's own `Message-ID`. */
298
+ messageId: string;
299
+ }
300
+ ```
301
+
302
+ `NormalizedMessage` is what a transport receives: every field of `Message` with defaults
303
+ applied, address fields as lists, `date` and `messageId` always present, a derived `text`
304
+ part, and `email` carrying the source `Email` when the message came from one — which
305
+ transports ignore and tests use to identify a send by type. `RenderedEmail` is
306
+ `{ html, text }`, `SendOptions` is `Partial<Message>`, `MailerOptions` is the
307
+ constructor's options object, `EmailTableRow` is one `{ label, value }` row of an
308
+ `Email.Table`, and `EmailFont` is one web font for `Email.Layout` to declare: `family`, a
309
+ required `fallback` stack, and optional `src`, `weight` and `style`.
310
+
311
+ ### `@sdxc/mail/markdown`
312
+
313
+ #### `Markdown`
314
+
315
+ Renders a parsed document through the layout kit. Props: `document`. Parsing stays with
316
+ the caller, so one document renders in an inbox and on a page, and mail that carries no
317
+ markdown pays for no parser.
318
+
319
+ The mapping is lossy where an inbox cannot lay out what markdown expresses: headings stop
320
+ at level three, a table becomes a real `<table>`, an alert becomes a labelled block quote,
321
+ and a footnote's body becomes a labelled block under the prose pointing at it. Raw HTML,
322
+ block and inline alike, arrives as escaped text, which keeps a document from any source
323
+ safe to send.
324
+
325
+ #### `CodeBlock`
326
+
327
+ A fenced block of code, highlighted, inside a single-cell table — Outlook paints a `<pre>`
328
+ with a background to the width of its text rather than the column. Props: `code`,
329
+ `language?`, `tokens?`. A painted document carries its runs on the code node and
330
+ `Markdown` passes them through as `tokens`; given `code` alone the block tokenizes itself,
331
+ and an unknown language renders unpainted.
332
+
333
+ ### `@sdxc/mail/memory`
334
+
335
+ #### `new MemoryTransport(options?: MemoryTransportOptions)`
336
+
337
+ Records every delivery instead of sending it, so tests assert on real behavior rather than
338
+ on a mocked SDK module. `options.mime` also records the assembled raw message; it is off
339
+ by default because most tests read the normalized one.
340
+
341
+ - **`transport.messages`** — every recorded normalized message, oldest first
342
+ - **`transport.last`** — the most recent message, or `undefined`
343
+ - **`transport.deliveries`** — every delivery as `{ message, mime? }`, oldest first
344
+ - **`transport.lastMime`** — the raw MIME of the most recent delivery, or `undefined`
345
+ - **`transport.find(predicate)`** — the first message matching a predicate
346
+ - **`transport.clear()`** — forgets every delivery, so one instance serves several tests
347
+
348
+ ### `@sdxc/mail/cloudflare`
349
+
350
+ #### `new CloudflareTransport(binding: SendEmailBinding)`
351
+
352
+ Delivers through the Workers email sending binding, which composes the message from
353
+ structured fields, so the transport assembles no MIME and the app imports no platform
354
+ class. Success carries the identifier the platform assigned; a refusal arrives as a
355
+ `MailError` carrying the platform's rejection as `cause`.
356
+
357
+ The binding writes its own `Date` and `Message-ID`, so the values a normalized message
358
+ carries stay local, and it takes one `replyTo` mailbox rather than a list, so the first is
359
+ the one that ships. `SendEmailBinding`, `SendEmailMessage` and `SendEmailResult` are
360
+ exported as the seam those platform assumptions live behind, declared locally so this
361
+ package typechecks outside a Workers project. See the
362
+ [Cloudflare email routing docs](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/)
363
+ for declaring the binding and verifying a sending domain.
364
+
365
+ ### `@sdxc/mail/middleware`
366
+
367
+ #### `mail(options: MailMiddlewareOptions): Middleware`
368
+
369
+ Publishes a request-scoped `Mailer` as `context.email` and flushes its deferred queue once
370
+ `next()` resolves. `options` carries `transport` — a `Transport`, or a
371
+ `(context) => Transport` factory when it is resolved per request — plus `from`, and
372
+ optionally `replyTo` and `headers`.
373
+
374
+ A delivered message sets `mail.sent` on the invocation's log and notes the provider's
375
+ message id; a failed one is a `mail.send_failed` warning, since the response is already
376
+ out. With no log current the outcomes are dropped. The module augments `RequestContext`,
377
+ so `context.email` is typed wherever the middleware is imported. Despite the name, it is
378
+ the object that _sends_ mail, not the current user's address.
379
+
380
+ ## MIME Guarantees
381
+
382
+ What `buildMimeMessage` produces, for a transport that speaks raw messages:
383
+
384
+ - **Structure** — both body parts produce `multipart/alternative` with the plain-text part
385
+ first, which RFC 2046 reads as least to most preferred; a single part produces a
386
+ single-part message with no boundary.
387
+ - **Headers** — `From`, `To`, `Cc`, `Reply-To`, `Subject`, `Date`, `Message-ID`,
388
+ `MIME-Version`, then custom headers, then the `Content-*` headers. `Bcc` is absent,
389
+ since those recipients are addressed by the envelope, and a custom header repeating a
390
+ derived name is dropped rather than emitted twice.
391
+ - **Folding** — a header past 78 characters folds at an existing space and the
392
+ continuation keeps that space, so unfolding restores the value character for character.
393
+ A longer run with no space in it is left long, since folding inside a token corrupts it.
394
+ - **Encoded words** — a non-ASCII subject or display name becomes base64 RFC 2047 encoded
395
+ words, chunked on character boundaries and sized so the line still fits the limit. A
396
+ display name is encoded rather than quoted, because a quoted encoded word reaches the
397
+ reader literally.
398
+ - **Part encoding** — quoted-printable while text stays mostly ASCII, keeping the raw
399
+ message readable, and base64 once escaping would inflate the body more than base64 does.
400
+ - **Boundaries** — the boundary carries a random UUID and is checked against the encoded
401
+ bodies; base64's alphabet has no `-` and quoted-printable escapes a leading one, so no
402
+ body line reads as a delimiter.
403
+ - **Line endings** — every break is CRLF, whatever the caller's bodies used, and both
404
+ encodings wrap at 76 characters.
405
+
406
+ ## Dark Mode
407
+
408
+ `Email.Layout` declares `color-scheme: light dark` and ships the dark half of it, because
409
+ declaring one without shipping it is worse than declaring nothing: Apple Mail reads the
410
+ declaration as a promise the message paints its own dark mode and stops remapping colors.
411
+
412
+ Every element therefore carries two things. The inline style is the light baseline, kept
413
+ by clients that strip `<style>`. A class — `mail-page`, `mail-surface`, `mail-text`,
414
+ `mail-muted`, `mail-rule`, `mail-action`, `mail-action-label` — is what the layout's
415
+ `prefers-color-scheme: dark` block overrides with `!important`. A class is emitted only
416
+ where the caller left that color to the kit, so passing `color` to a `Heading` opts it out
417
+ of the dark rule.
418
+
419
+ Components of your own that paint inside the card pass their rules as `darkStyles`,
420
+ appended inside that same media query:
421
+
422
+ ```tsx
423
+ <Email.Layout darkStyles=".status-down{color:#f87171 !important;}">
424
+ ```
425
+
426
+ That stylesheet is a text node and therefore escaped, so CSS passed here uses descendant
427
+ and class selectors rather than `>` or `&`.
428
+
429
+ ## Pattern: Deciding Between `send()` And `later()`
430
+
431
+ `send()` when the outcome must be known before responding, so the caller branches on the
432
+ `Result` and changes the response:
433
+
434
+ ```typescript
435
+ import { isFailure } from "@sdxc/result";
436
+
437
+ let result = await mailer.send(new PasswordResetEmail(user));
438
+ if (isFailure(result)) return new Response("Could not send the reset email", { status: 502 });
439
+ ```
440
+
441
+ `later()` when a failed send must not fail the operation. Queued messages flush after the
442
+ response is produced, so they cannot influence it, and their failures are logged:
443
+
444
+ ```typescript
445
+ context.email.later(new TeamInviteEmail(invite));
446
+ return new Response(null, { status: 204 });
447
+ ```
448
+
449
+ That gives a fire-and-forget send a defined lifetime, which an unawaited promise does not
450
+ have.
451
+
452
+ ## Pattern: Translating A Subject For The Recipient
453
+
454
+ A subject belongs in the _reader's_ language, which is not always the request language: an
455
+ invite is composed by one person and read by another. The package resolves no locales — a
456
+ subject reaches it as a string the application already translated — so the application
457
+ picks the language and hands the translator to the constructor:
458
+
459
+ ```typescript
460
+ let translate = await translatorFor(invite.locale);
461
+ let subject = translate("invite.subject", { team: invite.team });
462
+
463
+ await mailer.send(new TeamInviteEmail({ ...invite, subject }));
464
+ ```
465
+
466
+ Choose that locale in this order: the recipient's stored preference, the locale recorded
467
+ on the record being acted on, then the app's fallback. The requester's own locale is the
468
+ last resort, since the common mistake is sending in the sender's language rather than the
469
+ reader's.
470
+
471
+ ## Pattern: Rendering Markdown As A Body
472
+
473
+ A document parsed elsewhere renders into the same card as a hand-authored body, with its
474
+ code fences painted:
475
+
476
+ ```tsx
477
+ import { highlight } from "@sdxc/highlight/markdown";
478
+ import { Email, render } from "@sdxc/mail";
479
+ import { Markdown as MarkdownBody } from "@sdxc/mail/markdown";
480
+ import { Markdown } from "@sdxc/markdown";
481
+ import { unwrap } from "@sdxc/result";
482
+
483
+ let { document } = unwrap(Markdown.parse(notes));
484
+ let painted = unwrap(Markdown.walk(document, highlight));
485
+
486
+ let { html, text } = await render(
487
+ <Email.Layout title="Release notes">
488
+ <MarkdownBody document={painted} />
489
+ </Email.Layout>,
490
+ );
491
+
492
+ await mailer.send({ to: { email: "user@example.com" }, subject: "Release notes", html, text });
493
+ ```
494
+
495
+ Highlighting is optional: a document handed over unpainted still renders, with each block
496
+ tokenized as it is drawn.
497
+
498
+ ## Pattern: Switching Providers
499
+
500
+ Sender configuration is a plain object and the transport is the only provider-aware piece,
501
+ so a switch is one construction site:
502
+
503
+ ```typescript
504
+ let mailer = new Mailer({
505
+ transport: new CloudflareTransport(env.EMAIL), // the only line that changes
506
+ from: { email: "no-reply@example.com", name: "Example" },
507
+ replyTo: { email: "hello@example.com" },
508
+ });
509
+ ```
510
+
511
+ Emails, services and tests are untouched, which also makes a switch reversible. Providers
512
+ differ in what they carry, though: check any field a new transport is quiet about, and
513
+ verify the first deliveries for real, since headers, both body parts and spam placement
514
+ are only observable end to end.
515
+
516
+ ## Versioning
517
+
518
+ Releases are dated rather than semantic. A version is the UTC date it was published,
519
+ written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
520
+ release goes out per day.
521
+
522
+ Those numbers say when, not what: a later date means a later release and carries no
523
+ compatibility promise. Any release may change or remove an export.
524
+
525
+ Depend on one exact date, and move it when you are ready to take the change:
526
+
527
+ ```json
528
+ {
529
+ "dependencies": {
530
+ "@sdxc/mail": "2026.9.4"
531
+ }
532
+ }
533
+ ```
534
+
535
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every
536
+ later release in the same year. An exact version keeps the upgrade yours to schedule.
537
+
538
+ ## License
539
+
540
+ MIT
541
+
542
+ ## Author
543
+
544
+ [Sergio Xalambrí](https://sergiodxa.com)
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Transport for the Workers email sending binding, whose structured send call takes
3
+ * the same fields a normalized message already holds, so it maps them across and
4
+ * assembles no MIME of its own. The binding is injected rather than read from an
5
+ * ambient global, which keeps the platform surface a single seam this package can be
6
+ * tested and corrected at.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import type { Result } from "@sdxc/result";
12
+ import type { NormalizedMessage, SentMessage, Transport } from "./types.js";
13
+ import { MailError } from "./errors.js";
14
+ /**
15
+ * The send payload the binding accepts, narrowed to the fields this package fills.
16
+ * Addresses are mailbox strings, since a formatted string carries the display name
17
+ * just as well as a name/email object and keeps every transport's mapping alike.
18
+ */
19
+ export interface SendEmailMessage {
20
+ /** Sender mailbox; the domain has to be verified for the account. */
21
+ from: string;
22
+ /** Primary recipients; the platform needs at least one of these three lists. */
23
+ to: string[];
24
+ /** Carbon-copy recipients. */
25
+ cc?: string[];
26
+ /** Blind carbon-copy recipients, which reach the envelope but no header. */
27
+ bcc?: string[];
28
+ /** Where replies go; the platform takes one mailbox, not a list. */
29
+ replyTo?: string;
30
+ /** Subject line. */
31
+ subject: string;
32
+ /** Plain-text body. */
33
+ text?: string;
34
+ /** HTML body. */
35
+ html?: string;
36
+ /** Extra headers to set on the assembled message. */
37
+ headers?: Record<string, string>;
38
+ }
39
+ /** What the binding reports back for an accepted message. */
40
+ export interface SendEmailResult {
41
+ /** Identifier the platform assigned, which is what its delivery logs are keyed by. */
42
+ messageId: string;
43
+ }
44
+ /**
45
+ * The part of the send-email binding this package uses. Declared here rather than
46
+ * taken from an ambient global so the transport can be exercised with a double, and
47
+ * so a correction to the platform's surface lands in one place.
48
+ */
49
+ export interface SendEmailBinding {
50
+ /**
51
+ * Composes and delivers one message.
52
+ *
53
+ * @param message - The fields to build the message from; see {@link SendEmailMessage}.
54
+ * @returns The platform's identifier for the accepted message.
55
+ */
56
+ send(message: SendEmailMessage): Promise<SendEmailResult>;
57
+ }
58
+ /**
59
+ * Transport that delivers through the Workers email sending binding, which writes
60
+ * its own `Date` and `Message-ID` and returns the identifier its delivery logs are
61
+ * keyed by, enforcing sender and destination limits from its configuration on send.
62
+ *
63
+ * @example
64
+ * let transport = new CloudflareTransport(env.EMAIL);
65
+ * let mailer = new Mailer({ transport, from: SENDER });
66
+ */
67
+ export declare class CloudflareTransport implements Transport {
68
+ private binding;
69
+ /**
70
+ * Creates the transport around an app's binding.
71
+ *
72
+ * @param binding - The `send_email` binding declared in the app's Wrangler configuration.
73
+ */
74
+ constructor(binding: SendEmailBinding);
75
+ /**
76
+ * Hands the message's fields to the binding, which composes and delivers it.
77
+ *
78
+ * @param message - The normalized message to deliver.
79
+ * @returns The identifier the platform assigned on success, a `MailError` on rejection.
80
+ */
81
+ send(message: NormalizedMessage): Promise<Result<SentMessage, MailError>>;
82
+ }