@sdxc/mail 2026.10.16 → 2026.10.20
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 +169 -78
- 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 +15 -7
- package/dist/index.js +9 -5
- 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/memory.d.ts +1 -1
- package/dist/memory.js +1 -1
- 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 +14 -5
package/README.md
CHANGED
|
@@ -44,66 +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
|
-
|
|
57
|
+
export default emails({
|
|
58
|
+
teams: { invite: email({ props: s.object({ team: s.string(), url: s.string() }) }) },
|
|
59
|
+
});
|
|
53
60
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
// app/emails/teams/invite.tsx
|
|
62
|
+
import { createEmail, Email } from "@sdxc/mail";
|
|
63
|
+
|
|
64
|
+
export default createEmail(emails.teams.invite, {
|
|
65
|
+
subject: ({ team, t }) => t("emails.teamInvite.subject", { team }),
|
|
66
|
+
body: (handle) => () => (
|
|
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>
|
|
61
71
|
</Email.Layout>
|
|
62
|
-
)
|
|
63
|
-
}
|
|
72
|
+
),
|
|
73
|
+
preview: () => ({ team: "Acme", url: "https://example.com/invite/1" }),
|
|
74
|
+
});
|
|
64
75
|
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
// app/initializers/mail.ts
|
|
77
|
+
import { createMail, lazy } from "@sdxc/mail";
|
|
67
78
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
79
|
+
export const mail = createMail({ translator: translate });
|
|
80
|
+
mail.map(
|
|
81
|
+
emails.teams.invite,
|
|
82
|
+
lazy(() => import("~/app/emails/teams/invite")),
|
|
83
|
+
);
|
|
84
|
+
```
|
|
71
85
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
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:
|
|
75
88
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
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.
|
|
80
101
|
|
|
81
|
-
|
|
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
|
+
});
|
|
82
115
|
```
|
|
83
116
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
```
|
|
87
137
|
|
|
88
138
|
### Publish a mailer on the request context
|
|
89
139
|
|
|
90
140
|
```typescript
|
|
91
141
|
import { CloudflareTransport } from "@sdxc/mail/cloudflare";
|
|
92
|
-
import
|
|
142
|
+
import mailer from "@sdxc/mail/middleware";
|
|
93
143
|
import { env } from "cloudflare:workers";
|
|
94
144
|
import { createRouter } from "remix/router";
|
|
95
145
|
|
|
96
146
|
let router = createRouter({
|
|
97
147
|
middleware: [
|
|
98
|
-
|
|
148
|
+
mailer({
|
|
99
149
|
transport: new CloudflareTransport(env.EMAIL),
|
|
100
150
|
from: { email: "no-reply@example.com", name: "Example" },
|
|
151
|
+
mail,
|
|
152
|
+
queue,
|
|
101
153
|
}),
|
|
102
154
|
],
|
|
103
155
|
});
|
|
104
156
|
|
|
105
157
|
router.post("/invites", async (context) => {
|
|
106
|
-
context.email.later(
|
|
158
|
+
context.email.later(emails.teams.invite, { to: invite.email, props: invite });
|
|
107
159
|
return new Response(null, { status: 204 });
|
|
108
160
|
});
|
|
109
161
|
```
|
|
@@ -114,17 +166,16 @@ is produced, and each outcome lands on the invocation's log.
|
|
|
114
166
|
### Test against the memory transport
|
|
115
167
|
|
|
116
168
|
```typescript
|
|
117
|
-
import { Mailer } from "@sdxc/mail";
|
|
169
|
+
import { isComposedEmail, Mailer } from "@sdxc/mail";
|
|
118
170
|
import { MemoryTransport } from "@sdxc/mail/memory";
|
|
119
171
|
|
|
120
172
|
let transport = new MemoryTransport();
|
|
121
|
-
let mailer = new Mailer({ transport, from: { email: "no-reply@example.com" } });
|
|
173
|
+
let mailer = new Mailer({ transport, from: { email: "no-reply@example.com" }, mail });
|
|
122
174
|
|
|
123
|
-
await mailer.send(
|
|
175
|
+
await mailer.send(emails.teams.invite, { to: invite.email, props: invite });
|
|
124
176
|
|
|
125
|
-
|
|
126
|
-
expect(
|
|
127
|
-
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);
|
|
128
179
|
```
|
|
129
180
|
|
|
130
181
|
Recorded messages are the normalized ones a provider receives: defaults applied, address
|
|
@@ -156,6 +207,41 @@ calls it for you.
|
|
|
156
207
|
|
|
157
208
|
How many messages wait for the next `flush()`.
|
|
158
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
|
+
|
|
159
245
|
### `render(element: RemixElement): Promise<RenderedEmail>`
|
|
160
246
|
|
|
161
247
|
Renders a body tree to `{ html, text }`, deriving `text` from the HTML. A whole document gets
|
|
@@ -285,10 +371,22 @@ binding.
|
|
|
285
371
|
|
|
286
372
|
Publishes a request-scoped `Mailer` as `context.email` and flushes its queue after `next()`.
|
|
287
373
|
`options` takes `transport` (a `Transport`, or `(context) => Transport` per request), `from`,
|
|
288
|
-
`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
|
|
289
375
|
on the current log. It publishes under the `CurrentMailer` context key, exported here and from
|
|
290
376
|
`./middleware/cli`, so `ctx.get(CurrentMailer)` reads the same mailer in a route or a command.
|
|
291
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
|
+
|
|
292
390
|
### `@sdxc/mail/middleware/cli`
|
|
293
391
|
|
|
294
392
|
#### `mail(options: MailCommandOptions): CommandMiddleware` (default export)
|
|
@@ -317,6 +415,14 @@ let cli = createCLI({
|
|
|
317
415
|
});
|
|
318
416
|
```
|
|
319
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
|
+
|
|
320
426
|
### `@sdxc/mail/unsubscribe`
|
|
321
427
|
|
|
322
428
|
#### `signUnsubscribeToken(secret: string, claims: { subject: string; list: string }, options?: UnsubscribeToken.SignOptions): Promise<Result<string, MailError>>`
|
|
@@ -345,18 +451,19 @@ send must not fail the request.
|
|
|
345
451
|
import { isFailure } from "@sdxc/result";
|
|
346
452
|
|
|
347
453
|
router.post("/password-reset", async (context) => {
|
|
348
|
-
let result = await context.email.send(
|
|
454
|
+
let result = await context.email.send(emails.passwordReset, { to: user.email, props: { url } });
|
|
349
455
|
if (isFailure(result)) return new Response("Could not send the email", { status: 502 });
|
|
350
456
|
return new Response(null, { status: 204 });
|
|
351
457
|
});
|
|
352
458
|
|
|
353
459
|
router.post("/invites", async (context) => {
|
|
354
|
-
context.email.later(
|
|
460
|
+
context.email.later(emails.teams.invite, { to: invite.email, props: invite }); // after the response, failures logged
|
|
355
461
|
return new Response(null, { status: 204 });
|
|
356
462
|
});
|
|
357
463
|
```
|
|
358
464
|
|
|
359
|
-
|
|
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.
|
|
360
467
|
|
|
361
468
|
## Pattern: One-Click Unsubscribe For Bulk Mail
|
|
362
469
|
|
|
@@ -364,48 +471,32 @@ Gmail and Yahoo require one-click unsubscribe on subscribed and marketing mail.
|
|
|
364
471
|
its target and list, and the mailer writes the headers:
|
|
365
472
|
|
|
366
473
|
```tsx
|
|
367
|
-
|
|
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";
|
|
479
|
+
|
|
480
|
+
export default createEmail(emails.digest, {
|
|
481
|
+
subject: () => "Your daily digest",
|
|
482
|
+
unsubscribe: ({ token }) => ({
|
|
483
|
+
url: `https://example.com/unsubscribe/${token}`,
|
|
484
|
+
mailto: "unsubscribe@example.com",
|
|
485
|
+
}),
|
|
486
|
+
list: () => ({ id: "daily-digest.example.com", name: "Daily digest" }),
|
|
487
|
+
body: () => () => (
|
|
488
|
+
<Email.Layout title="Daily digest">
|
|
489
|
+
<Email.Text>Here is what happened today.</Email.Text>
|
|
490
|
+
</Email.Layout>
|
|
491
|
+
),
|
|
492
|
+
});
|
|
368
493
|
|
|
369
|
-
|
|
494
|
+
// the sender
|
|
370
495
|
import { signUnsubscribeToken } from "@sdxc/mail/unsubscribe";
|
|
371
496
|
import { unwrap } from "@sdxc/result";
|
|
372
497
|
|
|
373
|
-
class DailyDigestEmail implements Email {
|
|
374
|
-
constructor(
|
|
375
|
-
private member: { id: string; email: string },
|
|
376
|
-
private token: string,
|
|
377
|
-
) {}
|
|
378
|
-
|
|
379
|
-
get to() {
|
|
380
|
-
return { email: this.member.email };
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
get subject() {
|
|
384
|
-
return "Your daily digest";
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
get unsubscribe(): Unsubscribe {
|
|
388
|
-
return {
|
|
389
|
-
url: `https://example.com/unsubscribe/${this.token}`,
|
|
390
|
-
mailto: "unsubscribe@example.com",
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
get list(): MailingList {
|
|
395
|
-
return { id: "daily-digest.example.com", name: "Daily digest" };
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
body() {
|
|
399
|
-
return (
|
|
400
|
-
<Email.Layout title="Daily digest">
|
|
401
|
-
<Email.Text>Here is what happened today.</Email.Text>
|
|
402
|
-
</Email.Layout>
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
498
|
let token = unwrap(await signUnsubscribeToken(secret, { subject: member.id, list: "digest" }));
|
|
408
|
-
await
|
|
499
|
+
await ctx.email.send(emails.digest, { to: member.email, props: { token } });
|
|
409
500
|
```
|
|
410
501
|
|
|
411
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,6 +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 { 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";
|
|
13
17
|
export type { MailerOptions, SendOptions } from "./mailer.js";
|
|
14
18
|
export type { RenderedEmail } from "./render.js";
|
|
15
19
|
/**
|
|
@@ -20,15 +24,19 @@ export type { RenderedEmail } from "./render.js";
|
|
|
20
24
|
export type EmailTableRow = EmailComponents.Table.Row;
|
|
21
25
|
/** A web font for `Email.Layout` to declare, named here for the same reason as above. */
|
|
22
26
|
export type EmailFont = EmailComponents.Font;
|
|
23
|
-
export {
|
|
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";
|
|
24
32
|
export { formatAddress, toAddressList } from "./lib/address.js";
|
|
25
33
|
export { htmlToText } from "./lib/html-to-text.js";
|
|
26
34
|
export { isEmail, Mailer } from "./mailer.js";
|
|
27
35
|
export { buildMimeMessage } from "./mime.js";
|
|
28
36
|
export { render } from "./render.js";
|
|
29
37
|
/**
|
|
30
|
-
* An email
|
|
31
|
-
*
|
|
38
|
+
* An email: who it goes to, what it says, and what it looks like, handed
|
|
39
|
+
* straight to `send()` or `later()`. Declared as an alias
|
|
32
40
|
* so it can share the `Email` identifier with the layout kit below.
|
|
33
41
|
*/
|
|
34
42
|
export type Email = EmailContract;
|
package/dist/index.js
CHANGED
|
@@ -1,14 +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 {
|
|
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";
|
|
12
16
|
export { formatAddress, toAddressList } from "./lib/address.js";
|
|
13
17
|
export { htmlToText } from "./lib/html-to-text.js";
|
|
14
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
|
+
}>;
|