@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 +21 -0
- package/README.md +544 -0
- package/dist/cloudflare.d.ts +82 -0
- package/dist/cloudflare.js +71 -0
- package/dist/components.d.ts +346 -0
- package/dist/components.js +293 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +24 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +22 -0
- package/dist/lib/address.d.ts +37 -0
- package/dist/lib/address.js +56 -0
- package/dist/lib/html-to-text.d.ts +19 -0
- package/dist/lib/html-to-text.js +149 -0
- package/dist/mailer.d.ts +82 -0
- package/dist/mailer.js +188 -0
- package/dist/markdown.d.ts +46 -0
- package/dist/markdown.js +182 -0
- package/dist/memory.d.ts +81 -0
- package/dist/memory.js +84 -0
- package/dist/middleware.d.ts +53 -0
- package/dist/middleware.js +50 -0
- package/dist/mime.d.ts +21 -0
- package/dist/mime.js +374 -0
- package/dist/render.d.ts +27 -0
- package/dist/render.js +33 -0
- package/dist/types.d.ts +116 -0
- package/dist/types.js +8 -0
- package/package.json +30 -0
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
|
+
}
|