@sdxc/mail 2026.10.17 → 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/dist/mailer.js CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { failure, isFailure, success, wrap } from "@sdxc/result";
12
12
  import { generateUUID } from "@sdxc/uuid/v4";
13
+ import { isEmailDeclaration } from "./emails.js";
13
14
  import { MailError } from "./errors.js";
14
15
  import { isValidAddress, toAddressList } from "./lib/address.js";
15
16
  import { htmlToText } from "./lib/html-to-text.js";
@@ -89,6 +90,10 @@ function validate(message) {
89
90
  }
90
91
  return success(message);
91
92
  }
93
+ /** The failure a declared email meets on a mailer opened without the app's mail instance. */
94
+ function noRegistry() {
95
+ return new MailError("This mailer sends declared emails only when configured with the app's `createMail()` instance.");
96
+ }
92
97
  /**
93
98
  * Sends mail through a transport, applying one app's sender identity to every
94
99
  * message. The instance is request-scoped when `later()` is used, because the
@@ -97,7 +102,7 @@ function validate(message) {
97
102
  export class Mailer {
98
103
  /** Configuration shared by every message this mailer sends. */
99
104
  #options;
100
- /** Messages queued by `later()`, drained by `flush()`. */
105
+ /** Sends deferred by `later()`, drained by `flush()`. */
101
106
  #queue = [];
102
107
  /**
103
108
  * Creates a mailer.
@@ -111,17 +116,9 @@ export class Mailer {
111
116
  get pending() {
112
117
  return this.#queue.length;
113
118
  }
114
- /**
115
- * Normalizes and delivers a message, awaiting the outcome. Never throws: a
116
- * render failure, an invalid message, a rejected delivery, and a transport
117
- * that throws all arrive as a `MailError` failure for the caller to inspect.
118
- *
119
- * @param input - A plain message, or an email object that renders its own body.
120
- * @param overrides - Fields that replace what the input provides for this send.
121
- * @returns The provider's identifier on success, a `MailError` on failure.
122
- * @example let result = await mailer.send(new TeamInviteEmail(invite.email, invite));
123
- */
124
119
  async send(input, overrides) {
120
+ if (isEmailDeclaration(input))
121
+ return await this.#sendDeclared(input, overrides);
125
122
  let prepared = await this.#normalize(input, overrides);
126
123
  if (isFailure(prepared))
127
124
  return prepared;
@@ -134,17 +131,49 @@ export class Mailer {
134
131
  }
135
132
  return outcome.data;
136
133
  }
134
+ later(input, overrides) {
135
+ this.#queue.push(() => isEmailDeclaration(input)
136
+ ? this.#sendDeclared(input, overrides)
137
+ : this.send(input, overrides));
138
+ }
137
139
  /**
138
- * Queues a message to be sent once the caller flushes, which is how a send whose
139
- * failure must not affect the response gets a defined lifetime. Nothing is
140
- * rendered or validated yet; that happens at flush time.
140
+ * Hands a declared email to the app's queue, so a job sends it with retries on its own
141
+ * schedule. The props are validated here, so a caller learns about bad props where it wrote
142
+ * them; they render, translated, when the job delivers them.
141
143
  *
142
- * @param input - A plain message, or an email object that renders its own body.
143
- * @param overrides - Fields that replace what the input provides for this send.
144
- * @example ctx.email.later(new PasswordResetEmail(user));
144
+ * @param email - The declared email.
145
+ * @param delivery - The recipient, the locale and the props, all plain JSON.
146
+ * @returns Success once the queue accepted the delivery; an `EmailRefusedError` for props the
147
+ * schema refuses, or a `MailError` when no queue is configured or it threw.
148
+ * @example await ctx.email.queue(emails.teams.invite, { to, locale, props: { team, url } });
145
149
  */
146
- later(input, overrides) {
147
- this.#queue.push({ input, overrides });
150
+ async queue(email, delivery) {
151
+ let { mail, queue } = this.#options;
152
+ if (mail === undefined)
153
+ return failure(noRegistry());
154
+ if (queue === undefined) {
155
+ return failure(new MailError("This mailer has no `queue` to hand deliveries to."));
156
+ }
157
+ let props = await mail.validate(email, delivery.props);
158
+ if (isFailure(props))
159
+ return props;
160
+ let queued = await wrap(() => queue({ ...delivery, email: email.name }));
161
+ if (isFailure(queued)) {
162
+ return failure(new MailError(`Failed to queue the "${email.name}" email.`, { cause: queued.error }));
163
+ }
164
+ return success(undefined);
165
+ }
166
+ /**
167
+ * Sends a delivery `queue()` handed to the app's queue, which is the whole of the job that
168
+ * receives it. An `EmailRefusedError` means no retry can send it; any other failure can.
169
+ *
170
+ * @param queued - The delivery as the job received it.
171
+ * @returns The provider's identifier on success, a `MailError` on failure.
172
+ * @example let sent = await ctx.email.deliver(ctx.input);
173
+ */
174
+ async deliver(queued) {
175
+ let { email, ...delivery } = queued;
176
+ return await this.#sendDeclared(email, delivery);
148
177
  }
149
178
  /**
150
179
  * Sends everything `later()` queued and empties the queue. Results come back in
@@ -155,7 +184,27 @@ export class Mailer {
155
184
  */
156
185
  async flush() {
157
186
  let queued = this.#queue.splice(0, this.#queue.length);
158
- return await Promise.all(queued.map((entry) => this.send(entry.input, entry.overrides)));
187
+ return await Promise.all(queued.map((deferred) => deferred()));
188
+ }
189
+ /**
190
+ * Composes a declared email through the app's mail instance and sends it, with the
191
+ * delivery's sender and copies applied over the mailer's.
192
+ */
193
+ async #sendDeclared(email, delivery) {
194
+ let mail = this.#options.mail;
195
+ if (mail === undefined)
196
+ return failure(noRegistry());
197
+ let composed = await mail.compose(email, delivery);
198
+ if (isFailure(composed))
199
+ return composed;
200
+ let overrides = {};
201
+ if (delivery.from !== undefined)
202
+ overrides.from = delivery.from;
203
+ if (delivery.cc !== undefined)
204
+ overrides.cc = delivery.cc;
205
+ if (delivery.bcc !== undefined)
206
+ overrides.bcc = delivery.bcc;
207
+ return await this.send(composed.data, overrides);
159
208
  }
160
209
  /**
161
210
  * Turns an email object into a plain message by reading its addressing and
@@ -9,6 +9,7 @@
9
9
  * @copyright Sergio Xalambrí 2026
10
10
  */
11
11
  import type { Middleware, RequestContext } from "remix/router";
12
+ import type { Mail, QueuedEmail } from "./mail.js";
12
13
  import type { Address, Transport } from "./types.js";
13
14
  import { Mailer } from "./mailer.js";
14
15
  export { CurrentMailer } from "./context.js";
@@ -39,6 +40,10 @@ export interface MailMiddlewareOptions {
39
40
  replyTo?: Address | Address[];
40
41
  /** Headers added to every message the request sends. */
41
42
  headers?: Record<string, string>;
43
+ /** The app's `createMail()` instance, which lets `ctx.email` send its declared emails. */
44
+ mail?: Mail;
45
+ /** Hands a declared email's delivery to the app's queue for `ctx.email.queue()`. */
46
+ queue?: (delivery: QueuedEmail) => Promise<void>;
42
47
  }
43
48
  /**
44
49
  * Publishes a request-scoped mailer as `context.email`, under the {@link CurrentMailer} key the
@@ -48,6 +53,6 @@ export interface MailMiddlewareOptions {
48
53
  * @param options - Transport and sender configuration; see {@link MailMiddlewareOptions}.
49
54
  * @returns A middleware that populates `context.email`.
50
55
  * @example
51
- * router.use(mail({ transport: new CloudflareTransport(env.EMAIL), from: SENDER }));
56
+ * router.use(mailer({ transport: new CloudflareTransport(env.EMAIL), from: SENDER, mail }));
52
57
  */
53
58
  export default function mail(options: MailMiddlewareOptions): Middleware;
@@ -21,7 +21,7 @@ export { CurrentMailer } from "./context.js";
21
21
  * @param options - Transport and sender configuration; see {@link MailMiddlewareOptions}.
22
22
  * @returns A middleware that populates `context.email`.
23
23
  * @example
24
- * router.use(mail({ transport: new CloudflareTransport(env.EMAIL), from: SENDER }));
24
+ * router.use(mailer({ transport: new CloudflareTransport(env.EMAIL), from: SENDER, mail }));
25
25
  */
26
26
  export default function mail(options) {
27
27
  return async (context, next) => {
@@ -31,6 +31,8 @@ export default function mail(options) {
31
31
  from: options.from,
32
32
  replyTo: options.replyTo,
33
33
  headers: options.headers,
34
+ mail: options.mail,
35
+ queue: options.queue,
34
36
  });
35
37
  context.set(CurrentMailer, mailer, { property: "email" });
36
38
  try {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A fetch-router middleware that shows every email the registry maps, in every language the
3
+ * app speaks, rendered from each template's `preview()` props. It is a development tool: an
4
+ * app mounts it only while developing, since it renders any email for anyone who asks.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Middleware } from "remix/router";
10
+ import type { Mail } from "./mail.js";
11
+ /** Where the previews are served and in which languages. */
12
+ export interface PreviewsOptions {
13
+ /**
14
+ * The languages each email is listed in, such as the app's supported languages. Without
15
+ * any, each email is listed once, in the translator's fallback.
16
+ */
17
+ locales?: readonly string[];
18
+ /**
19
+ * The path the list answers at; one email renders at `<path>/<name>`.
20
+ * @default "/_emails"
21
+ */
22
+ path?: string;
23
+ /**
24
+ * The stylesheets the page links, which carry `@sdxc/ui`'s reset, theme and the app's palette,
25
+ * such as the ones the app's document links; without any, the components render unthemed.
26
+ * @example stylesheets: () => assetResolver.getStylesheets("resources/layouts/document.tsx")
27
+ */
28
+ stylesheets?: () => readonly string[] | Promise<readonly string[]>;
29
+ /**
30
+ * Whether to answer at all; `false` passes every request on, which keeps the middleware in
31
+ * a typed chain while serving previews only in development, as `import.meta.env.DEV`.
32
+ * @default true
33
+ */
34
+ enabled?: boolean;
35
+ }
36
+ /**
37
+ * Answers `GET` on `options.path` with an inbox of every mapped email, the one `?email=` names
38
+ * open beside the list in the `?locale=` asked for, and on `<path>/<name>?locale=<locale>&format=html|text` with that email rendered from its
39
+ * `preview()`: the HTML document, or the subject and the plain-text part. An unmapped email or
40
+ * one without `preview()` answers 404; every other path and method goes to `next()`.
41
+ *
42
+ * @param mail - The app's email registry.
43
+ * @param options - The languages to list and the path to answer at.
44
+ * @example previews(mail, { locales: SUPPORTED_LANGUAGES, enabled: import.meta.env.DEV })
45
+ */
46
+ export declare function previews(mail: Mail, options?: PreviewsOptions): Middleware;
47
+ export default previews;
@@ -0,0 +1,229 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "remix/component/jsx-runtime";
2
+ import { isFailure } from "@sdxc/result";
3
+ import { bg, border, borderEdge, colorScheme, fg } from "@sdxc/u/color";
4
+ import { block, flexWrap, grid, gridTemplate, grow, hstack, vstack } from "@sdxc/u/layout";
5
+ import { divide, overflowY } from "@sdxc/u/overflow";
6
+ import { media } from "@sdxc/u/responsive";
7
+ import { bs, is, m, maxBs, minBs, minIs, p } from "@sdxc/u/size";
8
+ import { aria, hover } from "@sdxc/u/state";
9
+ import { font, fontSize, lineClamp, textDecoration, truncate, weight, whiteSpace, } from "@sdxc/u/typography";
10
+ import { Avatar, Badge, Empty, Heading, Item, Link, LinkButton, Text } from "@sdxc/ui";
11
+ import { renderToString } from "remix/component/server";
12
+ import { EmailRefusedError } from "./errors.js";
13
+ /**
14
+ * `path` with every trailing slash removed, by walking back from the end, so the time it takes
15
+ * grows with the path's length whatever the path holds.
16
+ */
17
+ function withoutTrailingSlashes(path) {
18
+ let end = path.length;
19
+ while (end > 0 && path[end - 1] === "/")
20
+ end -= 1;
21
+ return path.slice(0, end);
22
+ }
23
+ /** The headers of a page that a preview never caches, since the template may change on the next save. */
24
+ const NO_STORE = { "Cache-Control": "no-store" };
25
+ /**
26
+ * Answers `GET` on `options.path` with an inbox of every mapped email, the one `?email=` names
27
+ * open beside the list in the `?locale=` asked for, and on `<path>/<name>?locale=<locale>&format=html|text` with that email rendered from its
28
+ * `preview()`: the HTML document, or the subject and the plain-text part. An unmapped email or
29
+ * one without `preview()` answers 404; every other path and method goes to `next()`.
30
+ *
31
+ * @param mail - The app's email registry.
32
+ * @param options - The languages to list and the path to answer at.
33
+ * @example previews(mail, { locales: SUPPORTED_LANGUAGES, enabled: import.meta.env.DEV })
34
+ */
35
+ export function previews(mail, options = {}) {
36
+ let base = withoutTrailingSlashes(options.path ?? "/_emails");
37
+ let locales = options.locales ?? [];
38
+ let enabled = options.enabled ?? true;
39
+ let stylesheets = options.stylesheets ?? (() => []);
40
+ return async (ctx, next) => {
41
+ if (!enabled)
42
+ return next();
43
+ if (ctx.method !== "GET" && ctx.method !== "HEAD")
44
+ return next();
45
+ let path = ctx.url.pathname;
46
+ if (path === base)
47
+ return await inbox(mail, base, locales, ctx.url.searchParams, stylesheets);
48
+ if (!path.startsWith(`${base}/`))
49
+ return next();
50
+ let name = decodeURIComponent(path.slice(base.length + 1));
51
+ let locale = ctx.url.searchParams.get("locale") ?? undefined;
52
+ let format = ctx.url.searchParams.get("format") === "text" ? "text" : "html";
53
+ return await show(mail, name, locale, format);
54
+ };
55
+ }
56
+ /**
57
+ * The inbox page: every mapped email rendered in the chosen locale for its subject and snippet,
58
+ * and the selected one, by default the first previewable, opened in the reading pane.
59
+ */
60
+ async function inbox(mail, base, locales, query, stylesheets) {
61
+ let asked = query.get("locale") ?? undefined;
62
+ let locale = asked !== undefined && locales.includes(asked) ? asked : locales[0];
63
+ let view = query.get("view") === "text" ? "text" : "html";
64
+ let requested = query.get("email") ?? undefined;
65
+ let entries = [];
66
+ let texts = new Map();
67
+ for (let email of mail.mapped) {
68
+ if (!(await mail.hasPreview(email))) {
69
+ entries.push({ name: email.name, previewable: false });
70
+ continue;
71
+ }
72
+ let rendered = await mail.preview(email.name, locale);
73
+ if (isFailure(rendered)) {
74
+ entries.push({
75
+ name: email.name,
76
+ previewable: true,
77
+ failed: true,
78
+ subject: rendered.error.message,
79
+ });
80
+ continue;
81
+ }
82
+ texts.set(email.name, rendered.data.text);
83
+ entries.push({
84
+ name: email.name,
85
+ previewable: true,
86
+ subject: rendered.data.subject,
87
+ snippet: snippet(rendered.data.text, rendered.data.subject),
88
+ });
89
+ }
90
+ let selected = requested === undefined
91
+ ? entries.find((entry) => entry.previewable)
92
+ : entries.find((entry) => entry.name === requested);
93
+ let html = await renderToString(_jsx(PreviewInbox, { base: base, locales: locales, locale: locale, entries: entries, selected: selected, requested: requested, view: view, text: selected === undefined ? undefined : texts.get(selected.name), stylesheets: await stylesheets() }));
94
+ return new Response(`<!doctype html>${html}`, {
95
+ headers: { "Content-Type": "text/html; charset=utf-8", ...NO_STORE },
96
+ });
97
+ }
98
+ /** The text part collapsed to one line, with a leading repeat of the subject dropped, cut at 140 characters. */
99
+ function snippet(text, subject) {
100
+ let line = text.replace(/\s+/g, " ").trim();
101
+ if (line.startsWith(subject))
102
+ line = line.slice(subject.length).trim();
103
+ return line.length > 140 ? `${line.slice(0, 139).trimEnd()}…` : line;
104
+ }
105
+ /** One email rendered from its preview props, or the reason it cannot be. */
106
+ async function show(mail, name, locale, format) {
107
+ if (!mail.mapped.some((email) => email.name === name)) {
108
+ return plain(`No template is mapped for the "${name}" email.`, 404);
109
+ }
110
+ if (!(await mail.hasPreview(name))) {
111
+ return plain(`The "${name}" email has no preview(); add one to its template.`, 404);
112
+ }
113
+ let rendered = await mail.preview(name, locale);
114
+ if (isFailure(rendered)) {
115
+ let cause = rendered.error.cause instanceof Error ? `\n\n${rendered.error.cause.stack}` : "";
116
+ let status = rendered.error instanceof EmailRefusedError ? 422 : 500;
117
+ return plain(`${rendered.error.message}${cause}`, status);
118
+ }
119
+ if (format === "text")
120
+ return plain(`Subject: ${rendered.data.subject}\n\n${rendered.data.text}`);
121
+ return new Response(rendered.data.html, {
122
+ headers: { "Content-Type": "text/html; charset=utf-8", ...NO_STORE },
123
+ });
124
+ }
125
+ /** A plain-text answer, uncached. */
126
+ function plain(text, status = 200) {
127
+ return new Response(text, {
128
+ status,
129
+ headers: { "Content-Type": "text/plain; charset=utf-8", ...NO_STORE },
130
+ });
131
+ }
132
+ /** The URL of one email's preview in one locale and format. */
133
+ function previewHref(base, name, locale, format) {
134
+ let query = new URLSearchParams();
135
+ if (locale !== undefined)
136
+ query.set("locale", locale);
137
+ if (format === "text")
138
+ query.set("format", "text");
139
+ let search = query.size === 0 ? "" : `?${query}`;
140
+ return `${base}/${encodeURIComponent(name)}${search}`;
141
+ }
142
+ /** The URL of the inbox with one email open, in one locale and view. */
143
+ function inboxHref(base, name, locale, view) {
144
+ let query = new URLSearchParams({ email: name });
145
+ if (locale !== undefined)
146
+ query.set("locale", locale);
147
+ if (view === "text")
148
+ query.set("view", "text");
149
+ return `${base}?${query}`;
150
+ }
151
+ /**
152
+ * The inbox document, built from `@sdxc/ui` over the app's own stylesheets: a toolbar with the
153
+ * locale switch, every mapped email listed as a mail client lists messages, and a reading pane
154
+ * with the open email's HTML in a frame or its text part. It stacks into one column on a phone.
155
+ */
156
+ function PreviewInbox(handle) {
157
+ return () => {
158
+ let { base, locales, locale, entries, selected, requested, view, text, stylesheets } = handle.props;
159
+ let readable = selected?.previewable === true && selected.failed !== true;
160
+ return (_jsxs("html", { lang: "en", class: "system", mix: colorScheme("light dark"), children: [_jsxs("head", { children: [_jsx("meta", { charset: "utf-8" }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }), _jsx("title", { children: readable && selected?.subject !== undefined
161
+ ? `${selected.subject} · Email previews`
162
+ : "Email previews" }), stylesheets.map((href) => (_jsx("link", { rel: "stylesheet", href: href })))] }), _jsxs("body", { mix: [
163
+ grid(),
164
+ gridTemplate({ rows: "auto minmax(0, 1fr)" }),
165
+ bs("100dvh"),
166
+ m(0),
167
+ font("sans"),
168
+ bg("neutral.bg-tint"),
169
+ fg("neutral"),
170
+ media("(max-width: 48rem)", bs("auto")),
171
+ ], children: [_jsxs("header", { mix: [
172
+ hstack({ gap: 4, align: "center" }),
173
+ flexWrap(),
174
+ p(3, 4),
175
+ bg("neutral.bg"),
176
+ borderEdge("block-end", { width: 1, style: "solid", color: "neutral" }),
177
+ ], children: [_jsxs(Heading, { level: 1, mix: [hstack({ gap: 2, align: "center" }), fontSize("lg"), grow()], children: ["Email previews", _jsx(Badge, { color: "neutral", children: entries.length })] }), locales.length > 1 ? (_jsx("nav", { "aria-label": "Language", mix: [hstack({ gap: 1, align: "center" }), flexWrap()], children: locales.map((code) => (_jsx(LinkButton, { href: selected === undefined
178
+ ? `${base}?locale=${encodeURIComponent(code)}`
179
+ : inboxHref(base, selected.name, code, view), "aria-current": code === locale ? "true" : undefined, color: code === locale ? "brand" : "neutral", variant: code === locale ? "solid" : "ghost", size: "sm", children: code.toUpperCase() }))) })) : null] }), _jsxs("div", { mix: [
180
+ grid(),
181
+ gridTemplate({ columns: "minmax(18rem, 24rem) minmax(0, 1fr)" }),
182
+ minBs(0),
183
+ media("(max-width: 48rem)", gridTemplate({ columns: "minmax(0, 1fr)" })),
184
+ ], children: [_jsx("nav", { "aria-label": "Emails", mix: [
185
+ overflowY("auto"),
186
+ bg("neutral.bg"),
187
+ borderEdge("inline-end", { width: 1, style: "solid", color: "neutral" }),
188
+ divide(),
189
+ media("(max-width: 48rem)", [maxBs("45dvh"), border("none")]),
190
+ ], children: entries.length === 0 ? (_jsx(Empty, { children: _jsx(Empty.Title, { children: "No email is mapped on the registry yet." }) })) : (entries.map((entry) => (_jsx("a", { href: inboxHref(base, entry.name, locale, view), "aria-current": entry.name === selected?.name ? "true" : undefined, mix: [
191
+ block(),
192
+ p(3, 4),
193
+ borderEdge("inline-start", {
194
+ width: 3,
195
+ style: "solid",
196
+ color: "transparent",
197
+ }),
198
+ fg("neutral"),
199
+ textDecoration("none"),
200
+ hover(bg("neutral.bg-tint-hover")),
201
+ aria("current", "true", [
202
+ bg("brand.bg-tint"),
203
+ borderEdge("inline-start", { width: 3, style: "solid", color: "brand" }),
204
+ ]),
205
+ ], children: _jsxs(Item, { children: [_jsx(Item.Media, { children: _jsx(Avatar, { children: _jsx(Avatar.Fallback, { children: (entry.name.split(".").pop() ?? entry.name).charAt(0).toUpperCase() }) }) }), _jsxs(Item.Content, { children: [_jsx(Text, { mix: [fontSize("xs"), fg("neutral.muted"), font("mono")], children: entry.name }), _jsx(Item.Title, { mix: [truncate(), weight("semibold")], children: entry.previewable ? (_jsx("span", { mix: entry.failed === true ? fg("danger") : [], children: entry.subject })) : (_jsx(Badge, { color: "warning", children: "No preview" })) }), _jsx(Item.Description, { mix: lineClamp(2), children: entry.previewable
206
+ ? entry.snippet
207
+ : "Add preview() to its template to see it here." })] })] }) })))) }), _jsx("main", { mix: [vstack({ gap: 0 }), minBs(0), minIs(0)], children: !readable || selected === undefined ? (_jsx(Empty, { mix: m("auto"), color: selected?.failed === true ? "danger" : "neutral", children: _jsx(Empty.Title, { children: selected === undefined && requested !== undefined
208
+ ? `No template is mapped for the "${requested}" email.`
209
+ : selected === undefined
210
+ ? "Select an email to read it."
211
+ : selected.failed === true
212
+ ? selected.subject
213
+ : `The "${selected.name}" email has no preview(); add one to its template.` }) })) : (_jsxs(_Fragment, { children: [_jsxs("div", { mix: [
214
+ vstack({ gap: 2 }),
215
+ p(4, 5),
216
+ bg("neutral.bg"),
217
+ borderEdge("block-end", { width: 1, style: "solid", color: "neutral" }),
218
+ ], children: [_jsx(Heading, { level: 2, mix: fontSize("xl"), children: selected.subject }), _jsxs("div", { mix: [hstack({ gap: 3, align: "center" }), flexWrap()], children: [_jsx(Text, { mix: [grow(), fontSize("xs"), fg("neutral.muted"), font("mono")], children: locale === undefined ? selected.name : `${selected.name} · ${locale}` }), _jsx("nav", { "aria-label": "Format", mix: hstack({ gap: 1, align: "center" }), children: ["html", "text"].map((format) => (_jsx(LinkButton, { href: inboxHref(base, selected.name, locale, format), "aria-current": format === view ? "true" : undefined, color: format === view ? "brand" : "neutral", variant: format === view ? "solid" : "outline", size: "sm", children: format === "html" ? "HTML" : "Text" }))) }), _jsx(Link, { href: previewHref(base, selected.name, locale, view), target: "_blank", children: "Open raw" })] })] }), view === "html" ? (_jsx("iframe", { title: selected.subject, src: previewHref(base, selected.name, locale, "html"), mix: [grow(), is("full"), minBs("70dvh"), border("none"), bg("white")] })) : (_jsx("pre", { mix: [
219
+ grow(),
220
+ m(0),
221
+ p(5),
222
+ overflowY("auto"),
223
+ whiteSpace("pre-wrap"),
224
+ font("mono"),
225
+ fontSize("sm"),
226
+ ], children: text }))] })) })] })] })] }));
227
+ };
228
+ }
229
+ export default previews;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `createEmail()`: what one declared email says and looks like, written in its own module and
3
+ * mapped onto the registry. Every member reads the parsed props together with the reader's
4
+ * locale and its translator, so copy is translated when the email renders.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
10
+ import type { Handle, RemixNode } from "remix/component";
11
+ import type { EmailDeclaration } from "./emails.js";
12
+ import type { Address, CalendarPart, MailingList, Unsubscribe } from "./types.js";
13
+ /**
14
+ * Augmented by an app to type the translator its templates receive, so `t()` checks message
15
+ * keys against the app's bundles.
16
+ *
17
+ * @example declare module "@sdxc/mail" { interface MailTypes { translate: Translate } }
18
+ */
19
+ export interface MailTypes {
20
+ }
21
+ /** The translator a template calls: the app's, or one keyed by any string until it declares its own. */
22
+ export type EmailTranslate = MailTypes extends {
23
+ translate: infer Translate;
24
+ } ? Translate : (key: string, values?: Record<string, unknown>) => string;
25
+ /** The language an email renders in, and the translator bound to it. */
26
+ export interface EmailLocale {
27
+ /** The language the translator resolved, which the layout's `lang` should carry. */
28
+ locale: string;
29
+ t: EmailTranslate;
30
+ }
31
+ /**
32
+ * What every member of a template receives: the parsed props with `locale` and `t` beside
33
+ * them, so a body reads `handle.props.team` and `handle.props.t` from one object.
34
+ *
35
+ * @template Props - The props as the email's schema parses them.
36
+ */
37
+ export type EmailInput<Props> = Props & EmailLocale;
38
+ /**
39
+ * What one declared email says and looks like.
40
+ *
41
+ * @template Props - The props as the email's schema parses them.
42
+ * @template Input - The props as a caller writes them, which `preview()` returns.
43
+ */
44
+ export interface EmailTemplateOptions<Props, Input = Props> {
45
+ /** Subject line, translated for the reader. */
46
+ subject(input: EmailInput<Props>): string;
47
+ /** Body component, rendered by the mailer into the HTML and plain-text parts. */
48
+ body: (handle: Handle<EmailInput<Props>>) => () => RemixNode;
49
+ /** Per-email reply-to; the mailer's configuration supplies the default. */
50
+ replyTo?(input: EmailInput<Props>): Address | Address[] | undefined;
51
+ /** Per-email headers, merged over the mailer's configured headers. */
52
+ headers?(input: EmailInput<Props>): Record<string, string> | undefined;
53
+ /** One-click unsubscribe for optional mail, built per reader since its URL names them. */
54
+ unsubscribe?(input: EmailInput<Props>): Unsubscribe | undefined;
55
+ /** List the email belongs to, written as `List-Id`. */
56
+ list?(input: EmailInput<Props>): MailingList | undefined;
57
+ /** A calendar object, such as an invitation, sent alongside the rendered body. */
58
+ calendar?(input: EmailInput<Props>): CalendarPart | undefined;
59
+ /**
60
+ * Sample props that show the email in a preview. Only previews read it, so an email
61
+ * without one still sends; its props pass through the schema like any caller's.
62
+ */
63
+ preview?(): Input;
64
+ }
65
+ /**
66
+ * A template paired with the email it was written for, which `map()` checks.
67
+ *
68
+ * @template Schema - The schema of the email's props.
69
+ */
70
+ export interface EmailTemplate<Schema extends StandardSchemaV1 = StandardSchemaV1> extends EmailTemplateOptions<StandardSchemaV1.InferOutput<Schema>, StandardSchemaV1.InferInput<Schema>> {
71
+ readonly email: EmailDeclaration<Schema>;
72
+ }
73
+ /** A template whatever its props, for the places that hold any of them. */
74
+ export type AnyEmailTemplate = EmailTemplate<any>;
75
+ /**
76
+ * Pairs a template with its declared email; passing the declaration is what types the props
77
+ * every member reads.
78
+ *
79
+ * @param email - The email this template renders, from the app's `emails()` map.
80
+ * @param options - The subject, the body component and any per-email delivery options.
81
+ * @returns The template, which the module default-exports for `mail.map()` to load.
82
+ * @example export default createEmail(emails.welcome, { subject: ({ name, t }) => t("emails.welcome.subject", { name }), body: WelcomeBody });
83
+ */
84
+ export declare function createEmail<Schema extends StandardSchemaV1>(email: EmailDeclaration<Schema>, options: EmailTemplateOptions<StandardSchemaV1.InferOutput<Schema>, StandardSchemaV1.InferInput<Schema>>): EmailTemplate<Schema>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `createEmail()`: what one declared email says and looks like, written in its own module and
3
+ * mapped onto the registry. Every member reads the parsed props together with the reader's
4
+ * locale and its translator, so copy is translated when the email renders.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * Pairs a template with its declared email; passing the declaration is what types the props
11
+ * every member reads.
12
+ *
13
+ * @param email - The email this template renders, from the app's `emails()` map.
14
+ * @param options - The subject, the body component and any per-email delivery options.
15
+ * @returns The template, which the module default-exports for `mail.map()` to load.
16
+ * @example export default createEmail(emails.welcome, { subject: ({ name, t }) => t("emails.welcome.subject", { name }), body: WelcomeBody });
17
+ */
18
+ export function createEmail(email, options) {
19
+ return { ...options, email };
20
+ }
package/dist/types.d.ts CHANGED
@@ -155,9 +155,9 @@ export interface Transport {
155
155
  send(message: NormalizedMessage): Promise<Result<SentMessage, MailError>>;
156
156
  }
157
157
  /**
158
- * An email: who it goes to, what it says, and what it looks like, usually built by
159
- * `defineEmail()`. Sender identity lives on the mailer, and a subject arrives already
160
- * translated from the caller's locale handling.
158
+ * An email: who it goes to, what it says, and what it looks like, usually composed by the
159
+ * registry from a declared email. Sender identity lives on the mailer, and a subject arrives
160
+ * already translated for its reader.
161
161
  */
162
162
  export interface Email {
163
163
  /** Recipient this email is addressed to, derived from the data it was constructed with. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/mail",
3
- "version": "2026.10.17",
3
+ "version": "2026.10.20",
4
4
  "description": "Transport-agnostic transactional email with pluggable transports and remix/component rendering",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,26 +12,35 @@
12
12
  "./print": "./dist/print.js",
13
13
  "./middleware": "./dist/middleware.js",
14
14
  "./middleware/cli": "./dist/cli.js",
15
+ "./middleware/jobs": "./dist/jobs.js",
16
+ "./previews": "./dist/previews.js",
15
17
  "./unsubscribe": "./dist/unsubscribe.js"
16
18
  },
17
19
  "dependencies": {
18
20
  "@sdxc/crypto": "2026.10.13",
19
21
  "@sdxc/highlight": "2026.10.16",
20
- "@sdxc/logger": "2026.10.16",
22
+ "@sdxc/logger": "2026.10.20",
21
23
  "@sdxc/markdown": "2026.10.16",
22
24
  "@sdxc/result": "2026.10.13",
23
- "@sdxc/uuid": "2026.10.13"
25
+ "@sdxc/u": "2026.10.20",
26
+ "@sdxc/ui": "2026.10.20",
27
+ "@sdxc/uuid": "2026.10.13",
28
+ "@standard-schema/spec": "^1.1.0"
24
29
  },
25
30
  "peerDependencies": {
26
- "@sdxc/cli": "2026.10.16",
31
+ "@sdxc/cli": "2026.10.20",
32
+ "@sdxc/jobs": "2026.10.20",
27
33
  "remix": "3.0.0"
28
34
  },
29
35
  "peerDependenciesMeta": {
30
36
  "@sdxc/cli": {
31
37
  "optional": true
38
+ },
39
+ "@sdxc/jobs": {
40
+ "optional": true
32
41
  }
33
42
  },
34
- "gitHead": "4f4bce453e9afe69f53194368af9e9739d73a876",
43
+ "gitHead": "bd4473246f405c43055d2fc5e08c62ba35197258",
35
44
  "publishConfig": {
36
45
  "access": "public"
37
46
  },