@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 +277 -416
- package/dist/mailer.js +2 -1
- package/dist/mime.js +3 -2
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -1,37 +1,26 @@
|
|
|
1
1
|
# @sdxc/mail
|
|
2
2
|
|
|
3
|
-
Transport-agnostic transactional email
|
|
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`
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
```
|
|
11
|
+
```sh
|
|
13
12
|
npm add @sdxc/mail
|
|
14
13
|
```
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
[`
|
|
20
|
-
|
|
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
|
|
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
|
|
57
|
-
second authoring step.
|
|
45
|
+
The plain-text part is derived from the HTML, so every message ships both.
|
|
58
46
|
|
|
59
|
-
### Author
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<Email.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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`
|
|
100
|
-
`implements Email` and `<Email.Layout
|
|
101
|
-
|
|
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
|
|
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
|
|
127
|
-
|
|
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
|
|
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
|
|
146
|
-
coerced
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
140
|
+
### `mailer.send(input: Message | Email, overrides?: SendOptions): Promise<Result<SentMessage, MailError>>`
|
|
157
141
|
|
|
158
|
-
Normalizes and delivers one message
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
146
|
+
### `mailer.later(input: Message | Email, overrides?: SendOptions): void`
|
|
164
147
|
|
|
165
|
-
Queues a message for the next `flush()
|
|
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
|
|
171
|
-
|
|
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
|
|
180
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
-
###
|
|
195
|
+
### `Email` layout kit
|
|
261
196
|
|
|
262
|
-
|
|
197
|
+
Unbranded components laid out with inline-styled tables, every color a prop.
|
|
263
198
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
`
|
|
341
|
-
|
|
342
|
-
`
|
|
343
|
-
|
|
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.
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
362
|
-
|
|
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
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
`
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
407
|
-
`
|
|
408
|
-
`
|
|
409
|
-
|
|
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
|
|
284
|
+
#### `signUnsubscribeToken(secret: string, claims: { subject: string; list: string }, options?: UnsubscribeToken.SignOptions): Promise<Result<string, MailError>>`
|
|
420
285
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
290
|
+
#### `verifyUnsubscribeToken(secret: string, token: string, options?: UnsubscribeToken.Options): Promise<Result<UnsubscribeToken.Claims, InvalidUnsubscribeTokenError>>`
|
|
429
291
|
|
|
430
|
-
Checks the MAC in constant time and
|
|
431
|
-
|
|
432
|
-
`InvalidUnsubscribeTokenError
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
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 {
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
return
|
|
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
|
-
|
|
496
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
530
|
-
|
|
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:
|
|
650
|
-
|
|
449
|
+
Highlighting is optional: an unpainted document still renders, each code block highlighting
|
|
450
|
+
itself.
|
|
651
451
|
|
|
652
|
-
## Pattern:
|
|
452
|
+
## Pattern: Brand Fonts And Dark Mode
|
|
653
453
|
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
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,
|
|
666
|
-
|
|
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 `<${
|
|
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}${
|
|
298
|
+
let boundary = `${BOUNDARY_PREFIX}${generateUUID()}`;
|
|
298
299
|
while (parts.some((part) => part.body.includes(boundary))) {
|
|
299
|
-
boundary = `${BOUNDARY_PREFIX}${
|
|
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.
|
|
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.
|
|
17
|
-
"@sdxc/highlight": "2026.10.
|
|
18
|
-
"@sdxc/logger": "2026.10.
|
|
19
|
-
"@sdxc/markdown": "2026.10.
|
|
20
|
-
"@sdxc/result": "2026.10.
|
|
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": "
|
|
24
|
+
"gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
|
|
24
25
|
"publishConfig": {
|
|
25
26
|
"access": "public"
|
|
26
27
|
},
|