@sdxc/mail 2026.10.17 → 2026.10.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -44
- package/dist/cli.js +2 -0
- package/dist/emails.d.ts +82 -0
- package/dist/emails.js +61 -0
- package/dist/errors.d.ts +9 -1
- package/dist/errors.js +9 -1
- package/dist/index.d.ts +13 -7
- package/dist/index.js +9 -6
- package/dist/jobs.d.ts +33 -0
- package/dist/jobs.js +43 -0
- package/dist/lazy.d.ts +31 -0
- package/dist/lazy.js +24 -0
- package/dist/mail.d.ts +169 -0
- package/dist/mail.js +265 -0
- package/dist/mailer.d.ts +43 -6
- package/dist/mailer.js +69 -20
- package/dist/middleware.d.ts +6 -1
- package/dist/middleware.js +3 -1
- package/dist/previews.d.ts +47 -0
- package/dist/previews.js +229 -0
- package/dist/template.d.ts +84 -0
- package/dist/template.js +20 -0
- package/dist/types.d.ts +3 -3
- package/package.json +15 -6
- package/dist/define.d.ts +0 -61
- package/dist/define.js +0 -48
package/README.md
CHANGED
|
@@ -44,56 +44,118 @@ if (isFailure(result)) console.warn(result.error.message);
|
|
|
44
44
|
|
|
45
45
|
The plain-text part is derived from the HTML, so every message ships both.
|
|
46
46
|
|
|
47
|
-
###
|
|
47
|
+
### Declare emails in a registry
|
|
48
|
+
|
|
49
|
+
An app declares every email it sends, and the schema of its props, in one map. Importing the
|
|
50
|
+
map costs the schemas alone, so a sender never loads the module that renders an email:
|
|
48
51
|
|
|
49
52
|
```tsx
|
|
50
|
-
|
|
53
|
+
// app/emails/index.ts
|
|
54
|
+
import { email, emails } from "@sdxc/mail";
|
|
55
|
+
import * as s from "remix/data-schema";
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
team: string
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
export default emails({
|
|
58
|
+
teams: { invite: email({ props: s.object({ team: s.string(), url: s.string() }) }) },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// app/emails/teams/invite.tsx
|
|
62
|
+
import { createEmail, Email } from "@sdxc/mail";
|
|
56
63
|
|
|
57
|
-
export
|
|
58
|
-
subject: (
|
|
64
|
+
export default createEmail(emails.teams.invite, {
|
|
65
|
+
subject: ({ team, t }) => t("emails.teamInvite.subject", { team }),
|
|
59
66
|
body: (handle) => () => (
|
|
60
|
-
<Email.Layout
|
|
61
|
-
<Email.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<Email.Footer>You received this because someone invited you to a team.</Email.Footer>
|
|
67
|
+
<Email.Layout lang={handle.props.locale} title={handle.props.team}>
|
|
68
|
+
<Email.Button href={handle.props.url}>
|
|
69
|
+
{handle.props.t("emails.teamInvite.action")}
|
|
70
|
+
</Email.Button>
|
|
65
71
|
</Email.Layout>
|
|
66
72
|
),
|
|
73
|
+
preview: () => ({ team: "Acme", url: "https://example.com/invite/1" }),
|
|
67
74
|
});
|
|
68
75
|
|
|
69
|
-
|
|
76
|
+
// app/initializers/mail.ts
|
|
77
|
+
import { createMail, lazy } from "@sdxc/mail";
|
|
78
|
+
|
|
79
|
+
export const mail = createMail({ translator: translate });
|
|
80
|
+
mail.map(
|
|
81
|
+
emails.teams.invite,
|
|
82
|
+
lazy(() => import("~/app/emails/teams/invite")),
|
|
83
|
+
);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A mailer configured with the registry as `mail` sends a declared email by its declaration, with
|
|
87
|
+
the recipient, the reader's language and the props:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
await ctx.email.send(emails.teams.invite, { to, locale, props: { team, url } });
|
|
91
|
+
ctx.email.later(emails.teams.invite, { to, locale, props: { team, url } });
|
|
92
|
+
await ctx.email.queue(emails.teams.invite, { to, locale, props: { team, url } });
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The props are parsed by the declared schema first, and copy is translated when the email
|
|
96
|
+
renders: `subject`, `body` and the other members receive the props with `locale` and `t` beside
|
|
97
|
+
them, from the registry's `translator` (`createTranslator()` from `@sdxc/i18n` fits). Augment
|
|
98
|
+
`MailTypes` with `translate` so `t()` checks message keys. `preview()` is optional and only feeds
|
|
99
|
+
`mail.preview()`, which `previews(mail, { locales })` from `@sdxc/mail/previews` serves at
|
|
100
|
+
`/_emails` while developing.
|
|
101
|
+
|
|
102
|
+
`queue()` hands the delivery, as plain JSON, to the mailer's `queue` option. One job delivers
|
|
103
|
+
every queued email:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// app/jobs/index.ts
|
|
107
|
+
export default jobs({ email: job({ input: EMAIL_DELIVERY }) });
|
|
108
|
+
|
|
109
|
+
// app/jobs/email.ts
|
|
110
|
+
export default createJobHandler(jobs.email, async (ctx) => {
|
|
111
|
+
let sent = await ctx.email.deliver(ctx.input);
|
|
112
|
+
if (isFailure(sent) && sent.error instanceof EmailRefusedError) ctx.exit(sent.error.message);
|
|
113
|
+
if (isFailure(sent)) ctx.retry({ cause: sent.error });
|
|
114
|
+
});
|
|
70
115
|
```
|
|
71
116
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
with `to`, `subject` and `body()`
|
|
76
|
-
|
|
117
|
+
### An email outside the registry
|
|
118
|
+
|
|
119
|
+
Mail that is no declared email, such as a message to the app's own inbox, can be any object
|
|
120
|
+
with `to`, `subject` and `body()`; `replyTo`, `headers`, `unsubscribe`, `list` and `calendar`
|
|
121
|
+
are optional members of the same `Email` contract.
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { Email } from "@sdxc/mail";
|
|
125
|
+
|
|
126
|
+
await mailer.send({
|
|
127
|
+
to: { email: "support@example.com" },
|
|
128
|
+
subject: `[Support] ${request.topic}`,
|
|
129
|
+
replyTo: { email: request.email },
|
|
130
|
+
body: () => (
|
|
131
|
+
<Email.Layout title="Support request">
|
|
132
|
+
<Email.Text>{request.message}</Email.Text>
|
|
133
|
+
</Email.Layout>
|
|
134
|
+
),
|
|
135
|
+
});
|
|
136
|
+
```
|
|
77
137
|
|
|
78
138
|
### Publish a mailer on the request context
|
|
79
139
|
|
|
80
140
|
```typescript
|
|
81
141
|
import { CloudflareTransport } from "@sdxc/mail/cloudflare";
|
|
82
|
-
import
|
|
142
|
+
import mailer from "@sdxc/mail/middleware";
|
|
83
143
|
import { env } from "cloudflare:workers";
|
|
84
144
|
import { createRouter } from "remix/router";
|
|
85
145
|
|
|
86
146
|
let router = createRouter({
|
|
87
147
|
middleware: [
|
|
88
|
-
|
|
148
|
+
mailer({
|
|
89
149
|
transport: new CloudflareTransport(env.EMAIL),
|
|
90
150
|
from: { email: "no-reply@example.com", name: "Example" },
|
|
151
|
+
mail,
|
|
152
|
+
queue,
|
|
91
153
|
}),
|
|
92
154
|
],
|
|
93
155
|
});
|
|
94
156
|
|
|
95
157
|
router.post("/invites", async (context) => {
|
|
96
|
-
context.email.later(
|
|
158
|
+
context.email.later(emails.teams.invite, { to: invite.email, props: invite });
|
|
97
159
|
return new Response(null, { status: 204 });
|
|
98
160
|
});
|
|
99
161
|
```
|
|
@@ -104,17 +166,16 @@ is produced, and each outcome lands on the invocation's log.
|
|
|
104
166
|
### Test against the memory transport
|
|
105
167
|
|
|
106
168
|
```typescript
|
|
107
|
-
import { Mailer } from "@sdxc/mail";
|
|
169
|
+
import { isComposedEmail, Mailer } from "@sdxc/mail";
|
|
108
170
|
import { MemoryTransport } from "@sdxc/mail/memory";
|
|
109
171
|
|
|
110
172
|
let transport = new MemoryTransport();
|
|
111
|
-
let mailer = new Mailer({ transport, from: { email: "no-reply@example.com" } });
|
|
173
|
+
let mailer = new Mailer({ transport, from: { email: "no-reply@example.com" }, mail });
|
|
112
174
|
|
|
113
|
-
await mailer.send(
|
|
175
|
+
await mailer.send(emails.teams.invite, { to: invite.email, props: invite });
|
|
114
176
|
|
|
115
|
-
|
|
116
|
-
expect(
|
|
117
|
-
expect(transport.last?.text).toContain(invite.url);
|
|
177
|
+
let sent = transport.find((message) => isComposedEmail(message.email, emails.teams.invite));
|
|
178
|
+
expect(sent?.text).toContain(invite.url);
|
|
118
179
|
```
|
|
119
180
|
|
|
120
181
|
Recorded messages are the normalized ones a provider receives: defaults applied, address
|
|
@@ -146,6 +207,41 @@ calls it for you.
|
|
|
146
207
|
|
|
147
208
|
How many messages wait for the next `flush()`.
|
|
148
209
|
|
|
210
|
+
### `mailer.queue(email, delivery): Promise<Result<void, MailError>>`
|
|
211
|
+
|
|
212
|
+
Validates a declared email's props and hands the delivery, with the email's name, to the
|
|
213
|
+
`queue` option. Fails without a `mail` registry or a `queue`.
|
|
214
|
+
|
|
215
|
+
### `mailer.deliver(queued: QueuedEmail): Promise<Result<SentMessage, MailError>>`
|
|
216
|
+
|
|
217
|
+
Sends a delivery `queue()` produced. An `EmailRefusedError` (unmapped name, refused props) means
|
|
218
|
+
no retry can send it.
|
|
219
|
+
|
|
220
|
+
### `email({ props })` and `emails(tree)`
|
|
221
|
+
|
|
222
|
+
Declare emails by the schema of their props; each declaration is named by its path
|
|
223
|
+
(`teams.invite`), which a queued delivery carries.
|
|
224
|
+
|
|
225
|
+
### `createEmail(email, options): EmailTemplate`
|
|
226
|
+
|
|
227
|
+
Pairs a template with its declaration: `subject`, `body`, and optional `replyTo`, `headers`,
|
|
228
|
+
`unsubscribe`, `list`, `calendar` and `preview`.
|
|
229
|
+
|
|
230
|
+
### `createMail(options?: MailOptions): Mail`
|
|
231
|
+
|
|
232
|
+
The registry: `map(email, template | lazy(load))`, `mapped`, `compose()`, `render(email, { locale, props })`,
|
|
233
|
+
`preview(email, locale?)`, `hasPreview(email)` and `validate(email, props)`. It holds no transport, so a command run
|
|
234
|
+
on Bun and the Worker share it.
|
|
235
|
+
|
|
236
|
+
### `EMAIL_DELIVERY`
|
|
237
|
+
|
|
238
|
+
The input schema of the job that delivers queued email.
|
|
239
|
+
|
|
240
|
+
### `isComposedEmail(email, declaration?)`
|
|
241
|
+
|
|
242
|
+
Whether a sent message's `email` was composed from a declaration, for picking it out of a
|
|
243
|
+
memory transport in a test.
|
|
244
|
+
|
|
149
245
|
### `render(element: RemixElement): Promise<RenderedEmail>`
|
|
150
246
|
|
|
151
247
|
Renders a body tree to `{ html, text }`, deriving `text` from the HTML. A whole document gets
|
|
@@ -159,11 +255,6 @@ headers fold at 78 characters, non-ASCII names and subjects become RFC 2047 enco
|
|
|
159
255
|
part is quoted-printable or base64, whichever is smaller, line endings are CRLF, and `Bcc` is
|
|
160
256
|
left to the envelope.
|
|
161
257
|
|
|
162
|
-
### `defineEmail<Props>(definition: EmailDefinition<Props>): EmailClass<Props>`
|
|
163
|
-
|
|
164
|
-
Builds an email class from `subject(props)` and a `body` component. Each call returns its own
|
|
165
|
-
class, so `instanceof` tells two emails apart; an instance exposes the `props` it was built with.
|
|
166
|
-
|
|
167
258
|
### `isEmail(value: Message | Email): value is Email`
|
|
168
259
|
|
|
169
260
|
`true` when the value has a callable `body`, the one member only an `Email` has.
|
|
@@ -280,10 +371,22 @@ binding.
|
|
|
280
371
|
|
|
281
372
|
Publishes a request-scoped `Mailer` as `context.email` and flushes its queue after `next()`.
|
|
282
373
|
`options` takes `transport` (a `Transport`, or `(context) => Transport` per request), `from`,
|
|
283
|
-
`replyTo
|
|
374
|
+
`replyTo?`, `headers?`, and `mail?` (the `createMail()` registry) with `queue?` for declared emails. Each deferred send logs `mail.sent` or a `mail.send_failed` warning
|
|
284
375
|
on the current log. It publishes under the `CurrentMailer` context key, exported here and from
|
|
285
376
|
`./middleware/cli`, so `ctx.get(CurrentMailer)` reads the same mailer in a route or a command.
|
|
286
377
|
|
|
378
|
+
### `@sdxc/mail/previews`
|
|
379
|
+
|
|
380
|
+
#### `previews(mail: Mail, options?: PreviewsOptions): Middleware` (also the default export)
|
|
381
|
+
|
|
382
|
+
Shows every mapped email as an inbox at `options.path` (default `/_emails`): each one's subject and
|
|
383
|
+
opening lines in the locale picked from `options.locales`, and the one `?email=` names open beside
|
|
384
|
+
the list as HTML or text (`?view=text`). The raw render of one email answers at
|
|
385
|
+
`<path>/<name>?locale=&format=html|text`. The page is built from `@sdxc/ui`; pass the app's document
|
|
386
|
+
stylesheets as `stylesheets` so it wears the app's theme:
|
|
387
|
+
`stylesheets: () => assetResolver.getStylesheets("resources/layouts/document.tsx")`. Serve it only in
|
|
388
|
+
development with `enabled: import.meta.env.DEV`, which passes every request on otherwise.
|
|
389
|
+
|
|
287
390
|
### `@sdxc/mail/middleware/cli`
|
|
288
391
|
|
|
289
392
|
#### `mail(options: MailCommandOptions): CommandMiddleware` (default export)
|
|
@@ -312,6 +415,14 @@ let cli = createCLI({
|
|
|
312
415
|
});
|
|
313
416
|
```
|
|
314
417
|
|
|
418
|
+
### `@sdxc/mail/middleware/jobs`
|
|
419
|
+
|
|
420
|
+
#### `mail(options: MailJobOptions): JobMiddleware` (default export)
|
|
421
|
+
|
|
422
|
+
Publishes a `Mailer` as `ctx.email` on every [`@sdxc/jobs`](https://www.npmjs.com/package/@sdxc/jobs)
|
|
423
|
+
run, with the router entry's options and `transport` a `Transport` or `(ctx) => Transport`. The
|
|
424
|
+
queue flushes after the handler; a failed deferred send is a `mail.send_failed` warning.
|
|
425
|
+
|
|
315
426
|
### `@sdxc/mail/unsubscribe`
|
|
316
427
|
|
|
317
428
|
#### `signUnsubscribeToken(secret: string, claims: { subject: string; list: string }, options?: UnsubscribeToken.SignOptions): Promise<Result<string, MailError>>`
|
|
@@ -340,18 +451,19 @@ send must not fail the request.
|
|
|
340
451
|
import { isFailure } from "@sdxc/result";
|
|
341
452
|
|
|
342
453
|
router.post("/password-reset", async (context) => {
|
|
343
|
-
let result = await context.email.send(
|
|
454
|
+
let result = await context.email.send(emails.passwordReset, { to: user.email, props: { url } });
|
|
344
455
|
if (isFailure(result)) return new Response("Could not send the email", { status: 502 });
|
|
345
456
|
return new Response(null, { status: 204 });
|
|
346
457
|
});
|
|
347
458
|
|
|
348
459
|
router.post("/invites", async (context) => {
|
|
349
|
-
context.email.later(
|
|
460
|
+
context.email.later(emails.teams.invite, { to: invite.email, props: invite }); // after the response, failures logged
|
|
350
461
|
return new Response(null, { status: 204 });
|
|
351
462
|
});
|
|
352
463
|
```
|
|
353
464
|
|
|
354
|
-
|
|
465
|
+
Use `queue()` when the email must survive a failed send: a job delivers it and retries. Outside
|
|
466
|
+
the middleware, call `mailer.flush()` yourself.
|
|
355
467
|
|
|
356
468
|
## Pattern: One-Click Unsubscribe For Bulk Mail
|
|
357
469
|
|
|
@@ -359,14 +471,16 @@ Gmail and Yahoo require one-click unsubscribe on subscribed and marketing mail.
|
|
|
359
471
|
its target and list, and the mailer writes the headers:
|
|
360
472
|
|
|
361
473
|
```tsx
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
474
|
+
// app/emails/index.ts
|
|
475
|
+
export default emails({ digest: email({ props: s.object({ token: s.string() }) }) });
|
|
476
|
+
|
|
477
|
+
// app/emails/digest.tsx
|
|
478
|
+
import { createEmail, Email } from "@sdxc/mail";
|
|
365
479
|
|
|
366
|
-
|
|
480
|
+
export default createEmail(emails.digest, {
|
|
367
481
|
subject: () => "Your daily digest",
|
|
368
|
-
unsubscribe: (
|
|
369
|
-
url: `https://example.com/unsubscribe/${
|
|
482
|
+
unsubscribe: ({ token }) => ({
|
|
483
|
+
url: `https://example.com/unsubscribe/${token}`,
|
|
370
484
|
mailto: "unsubscribe@example.com",
|
|
371
485
|
}),
|
|
372
486
|
list: () => ({ id: "daily-digest.example.com", name: "Daily digest" }),
|
|
@@ -377,8 +491,12 @@ const DailyDigestEmail = defineEmail<{ token: string }>({
|
|
|
377
491
|
),
|
|
378
492
|
});
|
|
379
493
|
|
|
494
|
+
// the sender
|
|
495
|
+
import { signUnsubscribeToken } from "@sdxc/mail/unsubscribe";
|
|
496
|
+
import { unwrap } from "@sdxc/result";
|
|
497
|
+
|
|
380
498
|
let token = unwrap(await signUnsubscribeToken(secret, { subject: member.id, list: "digest" }));
|
|
381
|
-
await
|
|
499
|
+
await ctx.email.send(emails.digest, { to: member.email, props: { token } });
|
|
382
500
|
```
|
|
383
501
|
|
|
384
502
|
```text
|
package/dist/cli.js
CHANGED
package/dist/emails.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The email registry's declarations: `emails({ … })` names every email an app sends and the
|
|
3
|
+
* schema of the props each one is built from. Importing the declarations costs the schemas
|
|
4
|
+
* alone, so a controller or a job sends an email without loading the module that renders it.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
10
|
+
/** Tells a declared email from a group of them, both being plain objects. */
|
|
11
|
+
declare const LEAF: unique symbol;
|
|
12
|
+
/** Tells a named declaration, as `emails()` returns it, from anything else a mailer receives. */
|
|
13
|
+
declare const DECLARATION: unique symbol;
|
|
14
|
+
/**
|
|
15
|
+
* One email as `email()` declares it, before `emails()` gives it the name its path spells.
|
|
16
|
+
*
|
|
17
|
+
* @template Schema - The schema of the props the email is built from.
|
|
18
|
+
*/
|
|
19
|
+
export interface EmailLeaf<Schema extends StandardSchemaV1> {
|
|
20
|
+
readonly [LEAF]: true;
|
|
21
|
+
readonly props: Schema;
|
|
22
|
+
}
|
|
23
|
+
type AnyEmailLeaf = EmailLeaf<any>;
|
|
24
|
+
/** The tree `emails()` takes: emails and groups of emails, nested to any depth. */
|
|
25
|
+
export interface EmailTree {
|
|
26
|
+
[key: string]: AnyEmailLeaf | EmailTree;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A declared email: its name, which a queued delivery carries, and the schema its props are
|
|
30
|
+
* validated against before it renders or enters a queue.
|
|
31
|
+
*
|
|
32
|
+
* @template Schema - The schema of the props the email is built from.
|
|
33
|
+
*/
|
|
34
|
+
export interface EmailDeclaration<Schema extends StandardSchemaV1 = StandardSchemaV1> {
|
|
35
|
+
readonly [DECLARATION]: true;
|
|
36
|
+
/** The email's path in the tree, joined with dots (`teams.invite`); stable while a delivery waits in a queue. */
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly props: Schema;
|
|
39
|
+
}
|
|
40
|
+
/** A declaration whatever its props, for the places that hold any of them. */
|
|
41
|
+
export type AnyEmailDeclaration = EmailDeclaration<any>;
|
|
42
|
+
/**
|
|
43
|
+
* The map `emails()` returns, with every leaf replaced by its named declaration.
|
|
44
|
+
*
|
|
45
|
+
* @template Tree - The tree the map was built from.
|
|
46
|
+
*/
|
|
47
|
+
export type EmailMap<Tree> = {
|
|
48
|
+
[Key in keyof Tree]: Tree[Key] extends EmailLeaf<infer Schema> ? EmailDeclaration<Schema> : EmailMap<Tree[Key]>;
|
|
49
|
+
};
|
|
50
|
+
/** The props a caller passes to send one email, before its schema parses them. */
|
|
51
|
+
export type EmailPropsInput<Declaration> = Declaration extends EmailDeclaration<infer Schema> ? StandardSchemaV1.InferInput<Schema> : never;
|
|
52
|
+
/** The props an email's template receives, as its schema parsed them. */
|
|
53
|
+
export type EmailPropsOutput<Declaration> = Declaration extends EmailDeclaration<infer Schema> ? StandardSchemaV1.InferOutput<Schema> : never;
|
|
54
|
+
/**
|
|
55
|
+
* Declares one email by the schema of its props. Any Standard Schema works; the props must be
|
|
56
|
+
* plain JSON when the email is queued, since a queued delivery travels as JSON.
|
|
57
|
+
*
|
|
58
|
+
* @param options - The schema the props are validated against.
|
|
59
|
+
* @returns A leaf for {@link emails} to name.
|
|
60
|
+
* @example email({ props: s.object({ team: s.string(), url: s.string() }) })
|
|
61
|
+
*/
|
|
62
|
+
export declare function email<Schema extends StandardSchemaV1>(options: {
|
|
63
|
+
props: Schema;
|
|
64
|
+
}): EmailLeaf<Schema>;
|
|
65
|
+
/**
|
|
66
|
+
* Names every email in `tree` by its path, so `emails({ teams: { invite } })` declares
|
|
67
|
+
* `teams.invite`.
|
|
68
|
+
*
|
|
69
|
+
* @param tree - The emails and groups of emails the app sends.
|
|
70
|
+
* @returns The same tree with each leaf turned into its named declaration.
|
|
71
|
+
* @example export default emails({ welcome: email({ props: s.object({ name: s.string() }) }) });
|
|
72
|
+
*/
|
|
73
|
+
export declare function emails<const Tree extends EmailTree>(tree: Tree): EmailMap<Tree>;
|
|
74
|
+
/**
|
|
75
|
+
* Reports whether a value is a declared email rather than a message or an email object, which
|
|
76
|
+
* is how one `send()` takes either.
|
|
77
|
+
*
|
|
78
|
+
* @param value - Whatever a mailer received.
|
|
79
|
+
* @returns `true` for a declaration `emails()` returned.
|
|
80
|
+
*/
|
|
81
|
+
export declare function isEmailDeclaration(value: unknown): value is AnyEmailDeclaration;
|
|
82
|
+
export {};
|
package/dist/emails.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The email registry's declarations: `emails({ … })` names every email an app sends and the
|
|
3
|
+
* schema of the props each one is built from. Importing the declarations costs the schemas
|
|
4
|
+
* alone, so a controller or a job sends an email without loading the module that renders it.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** Tells a declared email from a group of them, both being plain objects. */
|
|
10
|
+
const LEAF = Symbol.for("@sdxc/mail.email");
|
|
11
|
+
/** Tells a named declaration, as `emails()` returns it, from anything else a mailer receives. */
|
|
12
|
+
const DECLARATION = Symbol.for("@sdxc/mail.declaration");
|
|
13
|
+
/**
|
|
14
|
+
* Declares one email by the schema of its props. Any Standard Schema works; the props must be
|
|
15
|
+
* plain JSON when the email is queued, since a queued delivery travels as JSON.
|
|
16
|
+
*
|
|
17
|
+
* @param options - The schema the props are validated against.
|
|
18
|
+
* @returns A leaf for {@link emails} to name.
|
|
19
|
+
* @example email({ props: s.object({ team: s.string(), url: s.string() }) })
|
|
20
|
+
*/
|
|
21
|
+
export function email(options) {
|
|
22
|
+
return { [LEAF]: true, props: options.props };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Names every email in `tree` by its path, so `emails({ teams: { invite } })` declares
|
|
26
|
+
* `teams.invite`.
|
|
27
|
+
*
|
|
28
|
+
* @param tree - The emails and groups of emails the app sends.
|
|
29
|
+
* @returns The same tree with each leaf turned into its named declaration.
|
|
30
|
+
* @example export default emails({ welcome: email({ props: s.object({ name: s.string() }) }) });
|
|
31
|
+
*/
|
|
32
|
+
export function emails(tree) {
|
|
33
|
+
return build(tree, "");
|
|
34
|
+
}
|
|
35
|
+
/** Walks one level of the tree, prefixing every name with the path that reached it. */
|
|
36
|
+
function build(tree, prefix) {
|
|
37
|
+
let map = {};
|
|
38
|
+
for (let [key, value] of Object.entries(tree)) {
|
|
39
|
+
let name = prefix === "" ? key : `${prefix}.${key}`;
|
|
40
|
+
map[key] = isLeaf(value) ? declare(name, value) : build(value, name);
|
|
41
|
+
}
|
|
42
|
+
return map;
|
|
43
|
+
}
|
|
44
|
+
/** Reports whether a tree entry is an email rather than a group of them. */
|
|
45
|
+
function isLeaf(value) {
|
|
46
|
+
return LEAF in value;
|
|
47
|
+
}
|
|
48
|
+
/** The declaration of one leaf under the name its path spells. */
|
|
49
|
+
function declare(name, leaf) {
|
|
50
|
+
return { [DECLARATION]: true, name, props: leaf.props };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Reports whether a value is a declared email rather than a message or an email object, which
|
|
54
|
+
* is how one `send()` takes either.
|
|
55
|
+
*
|
|
56
|
+
* @param value - Whatever a mailer received.
|
|
57
|
+
* @returns `true` for a declaration `emails()` returned.
|
|
58
|
+
*/
|
|
59
|
+
export function isEmailDeclaration(value) {
|
|
60
|
+
return typeof value === "object" && value !== null && DECLARATION in value;
|
|
61
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The errors the mail package reports inside a `Result`: `MailError` for every
|
|
3
|
-
* send failure — invalid message, failed render, rejected delivery —
|
|
3
|
+
* send failure — invalid message, failed render, rejected delivery — its subclass
|
|
4
|
+
* `EmailRefusedError` for a declared email no retry can send, and
|
|
4
5
|
* `InvalidUnsubscribeTokenError` for a token the unsubscribe endpoint refuses.
|
|
5
6
|
*
|
|
6
7
|
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
@@ -20,6 +21,13 @@ export declare class MailError extends Error {
|
|
|
20
21
|
*/
|
|
21
22
|
constructor(message: string, options?: ErrorOptions);
|
|
22
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* A declared email that cannot be sent however often it is retried: its name is not mapped,
|
|
26
|
+
* or its props fail its schema. A queued delivery's job gives up on it instead of retrying.
|
|
27
|
+
*/
|
|
28
|
+
export declare class EmailRefusedError extends MailError {
|
|
29
|
+
name: string;
|
|
30
|
+
}
|
|
23
31
|
/**
|
|
24
32
|
* A token the unsubscribe endpoint cannot act on: malformed, tampered with, signed
|
|
25
33
|
* with another secret or purpose, or expired. The cases share one type so the
|
package/dist/errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The errors the mail package reports inside a `Result`: `MailError` for every
|
|
3
|
-
* send failure — invalid message, failed render, rejected delivery —
|
|
3
|
+
* send failure — invalid message, failed render, rejected delivery — its subclass
|
|
4
|
+
* `EmailRefusedError` for a declared email no retry can send, and
|
|
4
5
|
* `InvalidUnsubscribeTokenError` for a token the unsubscribe endpoint refuses.
|
|
5
6
|
*
|
|
6
7
|
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
@@ -22,6 +23,13 @@ export class MailError extends Error {
|
|
|
22
23
|
super(message, options);
|
|
23
24
|
}
|
|
24
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* A declared email that cannot be sent however often it is retried: its name is not mapped,
|
|
28
|
+
* or its props fail its schema. A queued delivery's job gives up on it instead of retrying.
|
|
29
|
+
*/
|
|
30
|
+
export class EmailRefusedError extends MailError {
|
|
31
|
+
name = "EmailRefusedError";
|
|
32
|
+
}
|
|
25
33
|
/**
|
|
26
34
|
* A token the unsubscribe endpoint cannot act on: malformed, tampered with, signed
|
|
27
35
|
* with another secret or purpose, or expired. The cases share one type so the
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Transport-agnostic transactional mail: the message contract, the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* behind their own subpaths so importing one never pulls another's dependency.
|
|
2
|
+
* Transport-agnostic transactional mail: the message contract, the email registry that maps
|
|
3
|
+
* declared emails onto their templates, the mailer that normalizes and delivers them, the
|
|
4
|
+
* rendering layer for a `remix/component` tree, and the layout kit for authoring one.
|
|
5
|
+
* Transports live behind their own subpaths so importing one never pulls another's dependency.
|
|
6
6
|
*
|
|
7
7
|
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
8
|
* @copyright Sergio Xalambrí 2026
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
import type { Email as EmailContract } from "./types.js";
|
|
11
11
|
import * as EmailComponents from "./components.js";
|
|
12
12
|
export type { Address, CalendarPart, MailingList, Message, NormalizedMessage, SentMessage, Transport, Unsubscribe, } from "./types.js";
|
|
13
|
-
export type {
|
|
13
|
+
export type { AnyEmailDeclaration, EmailDeclaration, EmailLeaf, EmailMap, EmailPropsInput, EmailPropsOutput, EmailTree, } from "./emails.js";
|
|
14
|
+
export type { Lazy } from "./lazy.js";
|
|
15
|
+
export type { ComposedEmail, DeliveryFor, EmailDelivery, EmailTranslator, MailOptions, QueuedEmail, RenderInput, RenderedDeclaredEmail, } from "./mail.js";
|
|
16
|
+
export type { AnyEmailTemplate, EmailInput, EmailLocale, EmailTemplate, EmailTemplateOptions, EmailTranslate, MailTypes, } from "./template.js";
|
|
14
17
|
export type { MailerOptions, SendOptions } from "./mailer.js";
|
|
15
18
|
export type { RenderedEmail } from "./render.js";
|
|
16
19
|
/**
|
|
@@ -21,8 +24,11 @@ export type { RenderedEmail } from "./render.js";
|
|
|
21
24
|
export type EmailTableRow = EmailComponents.Table.Row;
|
|
22
25
|
/** A web font for `Email.Layout` to declare, named here for the same reason as above. */
|
|
23
26
|
export type EmailFont = EmailComponents.Font;
|
|
24
|
-
export {
|
|
25
|
-
export { MailError } from "./errors.js";
|
|
27
|
+
export { email, emails, isEmailDeclaration } from "./emails.js";
|
|
28
|
+
export { EmailRefusedError, MailError } from "./errors.js";
|
|
29
|
+
export { lazy } from "./lazy.js";
|
|
30
|
+
export { createMail, EMAIL_DELIVERY, isComposedEmail, Mail } from "./mail.js";
|
|
31
|
+
export { createEmail } from "./template.js";
|
|
26
32
|
export { formatAddress, toAddressList } from "./lib/address.js";
|
|
27
33
|
export { htmlToText } from "./lib/html-to-text.js";
|
|
28
34
|
export { isEmail, Mailer } from "./mailer.js";
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Transport-agnostic transactional mail: the message contract, the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* behind their own subpaths so importing one never pulls another's dependency.
|
|
2
|
+
* Transport-agnostic transactional mail: the message contract, the email registry that maps
|
|
3
|
+
* declared emails onto their templates, the mailer that normalizes and delivers them, the
|
|
4
|
+
* rendering layer for a `remix/component` tree, and the layout kit for authoring one.
|
|
5
|
+
* Transports live behind their own subpaths so importing one never pulls another's dependency.
|
|
6
6
|
*
|
|
7
7
|
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
8
|
* @copyright Sergio Xalambrí 2026
|
|
9
9
|
*/
|
|
10
10
|
import * as EmailComponents from "./components.js";
|
|
11
|
-
export {
|
|
12
|
-
export { MailError } from "./errors.js";
|
|
11
|
+
export { email, emails, isEmailDeclaration } from "./emails.js";
|
|
12
|
+
export { EmailRefusedError, MailError } from "./errors.js";
|
|
13
|
+
export { lazy } from "./lazy.js";
|
|
14
|
+
export { createMail, EMAIL_DELIVERY, isComposedEmail, Mail } from "./mail.js";
|
|
15
|
+
export { createEmail } from "./template.js";
|
|
13
16
|
export { formatAddress, toAddressList } from "./lib/address.js";
|
|
14
17
|
export { htmlToText } from "./lib/html-to-text.js";
|
|
15
18
|
export { isEmail, Mailer } from "./mailer.js";
|
package/dist/jobs.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@sdxc/jobs` middleware that publishes a mailer to every job as `ctx.email`, so a job sends
|
|
3
|
+
* with the same templates and sender identity a route uses, and the job that delivers queued
|
|
4
|
+
* email reads the mailer the rest of its dispatcher shares.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { AnyJobContext, JobMiddleware } from "@sdxc/jobs";
|
|
10
|
+
import type { MailerOptions } from "./mailer.js";
|
|
11
|
+
import type { Transport } from "./types.js";
|
|
12
|
+
import { CurrentMailer } from "./context.js";
|
|
13
|
+
import { Mailer } from "./mailer.js";
|
|
14
|
+
export { CurrentMailer } from "./context.js";
|
|
15
|
+
/** Options that configure the job mail middleware. */
|
|
16
|
+
export interface MailJobOptions extends Omit<MailerOptions, "transport"> {
|
|
17
|
+
/** Transport used for delivery, or a factory that resolves one per run from the job's context. */
|
|
18
|
+
transport: Transport | ((ctx: AnyJobContext) => Transport);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Publishes a mailer as `ctx.email` for each run. Messages deferred with `later()` send once the
|
|
22
|
+
* handler returns and each outcome is recorded on the run's log; a failed deferred send is a
|
|
23
|
+
* warning there, since the job's own work already finished.
|
|
24
|
+
*
|
|
25
|
+
* @param options - Transport, sender, and the app's `createMail()` instance; see {@link MailJobOptions}.
|
|
26
|
+
* @returns A middleware that installs `ctx.email`.
|
|
27
|
+
* @example createJobDispatcher({ middleware: [mailer({ transport, from: MAIL_FROM, mail })] as const });
|
|
28
|
+
*/
|
|
29
|
+
export default function mail(options: MailJobOptions): JobMiddleware<{
|
|
30
|
+
key: typeof CurrentMailer;
|
|
31
|
+
value: Mailer;
|
|
32
|
+
property: "email";
|
|
33
|
+
}>;
|
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@sdxc/jobs` middleware that publishes a mailer to every job as `ctx.email`, so a job sends
|
|
3
|
+
* with the same templates and sender identity a route uses, and the job that delivers queued
|
|
4
|
+
* email reads the mailer the rest of its dispatcher shares.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { currentLog } from "@sdxc/logger";
|
|
10
|
+
import { isFailure } from "@sdxc/result";
|
|
11
|
+
import { CurrentMailer } from "./context.js";
|
|
12
|
+
import { Mailer } from "./mailer.js";
|
|
13
|
+
export { CurrentMailer } from "./context.js";
|
|
14
|
+
/**
|
|
15
|
+
* Publishes a mailer as `ctx.email` for each run. Messages deferred with `later()` send once the
|
|
16
|
+
* handler returns and each outcome is recorded on the run's log; a failed deferred send is a
|
|
17
|
+
* warning there, since the job's own work already finished.
|
|
18
|
+
*
|
|
19
|
+
* @param options - Transport, sender, and the app's `createMail()` instance; see {@link MailJobOptions}.
|
|
20
|
+
* @returns A middleware that installs `ctx.email`.
|
|
21
|
+
* @example createJobDispatcher({ middleware: [mailer({ transport, from: MAIL_FROM, mail })] as const });
|
|
22
|
+
*/
|
|
23
|
+
export default function mail(options) {
|
|
24
|
+
return async (ctx, next) => {
|
|
25
|
+
let transport = typeof options.transport === "function" ? options.transport(ctx) : options.transport;
|
|
26
|
+
let mailer = new Mailer({ ...options, transport });
|
|
27
|
+
ctx.set(CurrentMailer, mailer, { property: "email" });
|
|
28
|
+
try {
|
|
29
|
+
await next();
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
let log = currentLog();
|
|
33
|
+
for (let result of await mailer.flush()) {
|
|
34
|
+
if (isFailure(result))
|
|
35
|
+
log?.warn("mail.send_failed", { error: result.error.message });
|
|
36
|
+
else
|
|
37
|
+
log
|
|
38
|
+
?.set({ mail: { sent: true } })
|
|
39
|
+
.note("mail.sent", { message_id: result.data.messageId });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|