@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 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
- ### Author an email as a class
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
- import type { Handle } from "remix/component";
53
+ // app/emails/index.ts
54
+ import { email, emails } from "@sdxc/mail";
55
+ import * as s from "remix/data-schema";
51
56
 
52
- import { Email } from "@sdxc/mail";
57
+ export default emails({
58
+ teams: { invite: email({ props: s.object({ team: s.string(), url: s.string() }) }) },
59
+ });
53
60
 
54
- function InviteBody(handle: Handle<{ team: string; url: string }>) {
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
+ // 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
- export class TeamInviteEmail implements Email {
66
- constructor(private invite: { team: string; email: string; url: string; subject: string }) {}
76
+ // app/initializers/mail.ts
77
+ import { createMail, lazy } from "@sdxc/mail";
67
78
 
68
- get to() {
69
- return { email: this.invite.email };
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
- get subject() {
73
- return this.invite.subject;
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
- body() {
77
- return <InviteBody team={this.invite.team} url={this.invite.url} />;
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
- await mailer.send(new TeamInviteEmail(invite));
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
- `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.
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 mail from "@sdxc/mail/middleware";
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
- mail({
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(new TeamInviteEmail(invite));
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(new TeamInviteEmail(invite));
175
+ await mailer.send(emails.teams.invite, { to: invite.email, props: invite });
124
176
 
125
- expect(transport.messages).toHaveLength(1);
126
- expect(transport.last?.email).toBeInstanceOf(TeamInviteEmail);
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?` and `headers?`. Each deferred send logs `mail.sent` or a `mail.send_failed` warning
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(new PasswordResetEmail(user));
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(new TeamInviteEmail(invite)); // sent after the response, failures logged
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
- Outside the middleware, call `mailer.flush()` yourself, for example at the end of a job.
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
- import type { MailingList, Unsubscribe } from "@sdxc/mail";
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
- import { Email } from "@sdxc/mail";
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 mailer.send(new DailyDigestEmail(member, token));
499
+ await ctx.email.send(emails.digest, { to: member.email, props: { token } });
409
500
  ```
410
501
 
411
502
  ```text
package/dist/cli.js CHANGED
@@ -28,6 +28,8 @@ export default function mail(options) {
28
28
  from: options.from,
29
29
  replyTo: options.replyTo,
30
30
  headers: options.headers,
31
+ mail: options.mail,
32
+ queue: options.queue,
31
33
  });
32
34
  ctx.set(CurrentMailer, mailer, { property: "email" });
33
35
  let failed;
@@ -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 — and
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 — and
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 mailer that
3
- * normalizes and delivers it, the rendering layer that turns a `remix/component` tree into
4
- * both body parts, and the unbranded layout kit for authoring one. Transports live
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 { 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";
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 authored as a class: who it goes to, what it says, and what it
31
- * looks like, handed straight to `send()` or `later()`. Declared as an alias
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 mailer that
3
- * normalizes and delivers it, the rendering layer that turns a `remix/component` tree into
4
- * both body parts, and the unbranded layout kit for authoring one. Transports live
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 { 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";
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
+ }>;