@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/dist/jobs.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `@sdxc/jobs` middleware that publishes a mailer to every job as `ctx.email`, so a job sends
3
+ * with the same templates and sender identity a route uses, and the job that delivers queued
4
+ * email reads the mailer the rest of its dispatcher shares.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { currentLog } from "@sdxc/logger";
10
+ import { isFailure } from "@sdxc/result";
11
+ import { CurrentMailer } from "./context.js";
12
+ import { Mailer } from "./mailer.js";
13
+ export { CurrentMailer } from "./context.js";
14
+ /**
15
+ * Publishes a mailer as `ctx.email` for each run. Messages deferred with `later()` send once the
16
+ * handler returns and each outcome is recorded on the run's log; a failed deferred send is a
17
+ * warning there, since the job's own work already finished.
18
+ *
19
+ * @param options - Transport, sender, and the app's `createMail()` instance; see {@link MailJobOptions}.
20
+ * @returns A middleware that installs `ctx.email`.
21
+ * @example createJobDispatcher({ middleware: [mailer({ transport, from: MAIL_FROM, mail })] as const });
22
+ */
23
+ export default function mail(options) {
24
+ return async (ctx, next) => {
25
+ let transport = typeof options.transport === "function" ? options.transport(ctx) : options.transport;
26
+ let mailer = new Mailer({ ...options, transport });
27
+ ctx.set(CurrentMailer, mailer, { property: "email" });
28
+ try {
29
+ await next();
30
+ }
31
+ finally {
32
+ let log = currentLog();
33
+ for (let result of await mailer.flush()) {
34
+ if (isFailure(result))
35
+ log?.warn("mail.send_failed", { error: result.error.message });
36
+ else
37
+ log
38
+ ?.set({ mail: { sent: true } })
39
+ .note("mail.sent", { message_id: result.data.messageId });
40
+ }
41
+ }
42
+ };
43
+ }
package/dist/lazy.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Marks an email template as a module imported on first use, so a Worker starts without the
3
+ * body of every email it can send and the first send of one pays for its import once per
4
+ * isolate.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** Brands a loader so `map()` can tell it from a template it holds directly. */
10
+ declare const LAZY: unique symbol;
11
+ /** A template that arrives as the default export of a module imported on demand. */
12
+ export interface Lazy<Target> {
13
+ readonly [LAZY]: true;
14
+ /** Imports the module; `Mail` calls it once per isolate and shares the result. */
15
+ readonly load: () => Promise<{
16
+ default: Target;
17
+ }>;
18
+ }
19
+ /**
20
+ * Wraps a dynamic import so `map()` defers it to the first send of that email.
21
+ *
22
+ * @param load A function returning the module's `import()`; the module default-exports what `createEmail()` returns.
23
+ * @returns A binding `mail.map()` accepts wherever it takes a template.
24
+ * @example mail.map(emails.welcome, lazy(() => import("~/app/emails/welcome")));
25
+ */
26
+ export declare function lazy<Target>(load: () => Promise<{
27
+ default: Target;
28
+ }>): Lazy<Target>;
29
+ /** Reports whether a `map()` argument was wrapped by {@link lazy}. */
30
+ export declare function isLazy(value: unknown): value is Lazy<unknown>;
31
+ export {};
package/dist/lazy.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Marks an email template as a module imported on first use, so a Worker starts without the
3
+ * body of every email it can send and the first send of one pays for its import once per
4
+ * isolate.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** Brands a loader so `map()` can tell it from a template it holds directly. */
10
+ const LAZY = Symbol.for("@sdxc/mail.lazy");
11
+ /**
12
+ * Wraps a dynamic import so `map()` defers it to the first send of that email.
13
+ *
14
+ * @param load A function returning the module's `import()`; the module default-exports what `createEmail()` returns.
15
+ * @returns A binding `mail.map()` accepts wherever it takes a template.
16
+ * @example mail.map(emails.welcome, lazy(() => import("~/app/emails/welcome")));
17
+ */
18
+ export function lazy(load) {
19
+ return { [LAZY]: true, load };
20
+ }
21
+ /** Reports whether a `map()` argument was wrapped by {@link lazy}. */
22
+ export function isLazy(value) {
23
+ return typeof value === "object" && value !== null && LAZY in value;
24
+ }
package/dist/mail.d.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * `createMail()`: the app's email registry. It maps every declared email onto its template and
3
+ * composes one from its props and the reader's locale, so a send, a queued delivery and a
4
+ * preview all render the same way, wherever the mailer that sends it runs.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
11
+ import type { AnyEmailDeclaration, EmailPropsInput, EmailPropsOutput } from "./emails.js";
12
+ import type { Lazy } from "./lazy.js";
13
+ import type { RenderedEmail } from "./render.js";
14
+ import type { EmailTemplate, EmailTranslate } from "./template.js";
15
+ import type { Address, Email } from "./types.js";
16
+ import { MailError } from "./errors.js";
17
+ /**
18
+ * Who one declared email goes to, in which language, and the props it is built from. Every
19
+ * field is plain JSON, so the same delivery is sent now or handed to a queue.
20
+ *
21
+ * @template Props - The props as a caller writes them.
22
+ */
23
+ export interface EmailDelivery<Props = unknown> {
24
+ /** Recipients; a bare string is the mailbox alone. */
25
+ to: string | Address | Address[];
26
+ /** The reader's language, handed to the translator; omitted means the translator's fallback. */
27
+ locale?: string;
28
+ props: Props;
29
+ /** Sender for this delivery in place of the mailer's. */
30
+ from?: Address;
31
+ cc?: Address | Address[];
32
+ bcc?: Address | Address[];
33
+ /** Reply-to for this delivery, in place of the template's and the mailer's. */
34
+ replyTo?: Address | Address[];
35
+ /** Headers merged over the template's and the mailer's. */
36
+ headers?: Record<string, string>;
37
+ }
38
+ /** The delivery a caller writes for one declared email, typed from its schema. */
39
+ export type DeliveryFor<Declaration> = EmailDelivery<EmailPropsInput<Declaration>>;
40
+ /** A delivery addressed to a declared email by name, as it travels through a queue. */
41
+ export interface QueuedEmail extends EmailDelivery {
42
+ /** The declared email's name (`teams.invite`). */
43
+ email: string;
44
+ }
45
+ /** The props and the language a render needs, which a preview supplies without a recipient. */
46
+ export type RenderInput<Props = unknown> = Pick<EmailDelivery<Props>, "locale" | "props">;
47
+ /** A rendered email: its subject beside both body parts, as a preview or a test reads it. */
48
+ export interface RenderedDeclaredEmail extends RenderedEmail {
49
+ subject: string;
50
+ }
51
+ /**
52
+ * An email composed from a declaration, carrying the parsed props and the locale it rendered
53
+ * in, so a test finds a sent message by the email it came from and asserts on what the caller
54
+ * passed.
55
+ *
56
+ * @template Props - The props as the email's schema parsed them.
57
+ */
58
+ export interface ComposedEmail<Props = unknown> extends Email {
59
+ readonly declaration: AnyEmailDeclaration;
60
+ readonly props: Props;
61
+ readonly locale: string;
62
+ }
63
+ /** Binds the reader's language to a translator; `createTranslator()` from `@sdxc/i18n` returns one. */
64
+ export type EmailTranslator = (locale?: string) => {
65
+ locale: string;
66
+ t: EmailTranslate;
67
+ };
68
+ /** Configuration for the app's email registry. */
69
+ export interface MailOptions {
70
+ /**
71
+ * Binds each delivery's locale to the translator its template receives. Without one, a
72
+ * template's `t()` returns the key it was given and `locale` is the delivery's or `en`.
73
+ */
74
+ translator?: EmailTranslator;
75
+ }
76
+ /**
77
+ * The input of the job that delivers queued email: `job({ input: EMAIL_DELIVERY })`. The props
78
+ * pass through untouched here, since the declared email's own schema parses them on delivery.
79
+ */
80
+ export declare const EMAIL_DELIVERY: StandardSchemaV1<QueuedEmail, QueuedEmail>;
81
+ /**
82
+ * The app's email registry: every declared email mapped onto its template. It holds no
83
+ * transport, so the same instance serves the Worker's mailers and a command run on Bun; a
84
+ * mailer configured with it as `mail` sends, defers and queues declared emails.
85
+ */
86
+ export declare class Mail {
87
+ #private;
88
+ /**
89
+ * Creates the instance; {@link createMail} is the usual way to call it.
90
+ *
91
+ * @param options - The translator; see {@link MailOptions}.
92
+ */
93
+ constructor(options?: MailOptions);
94
+ /** Every mapped email, in the order it was mapped, for a preview list or a check to walk. */
95
+ get mapped(): AnyEmailDeclaration[];
96
+ /**
97
+ * Maps a declared email onto its template, held directly or loaded through `lazy()` on the
98
+ * email's first send.
99
+ *
100
+ * @param email - The declared email.
101
+ * @param template - What `createEmail()` returned for it, or the module that default-exports it.
102
+ * @throws When the email is already mapped, or the template was written for another email.
103
+ * @example mail.map(emails.welcome, lazy(() => import("~/app/emails/welcome")));
104
+ */
105
+ map<Schema extends StandardSchemaV1>(email: AnyEmailDeclaration & {
106
+ props: Schema;
107
+ }, template: EmailTemplate<Schema> | Lazy<EmailTemplate<Schema>>): void;
108
+ /**
109
+ * Builds the email a delivery describes: the props parsed by the declared schema, the
110
+ * locale bound to the translator, and every template member read from both.
111
+ *
112
+ * @param email - The declared email, or its name as a queued delivery carries it.
113
+ * @param delivery - The recipient, the locale and the props.
114
+ * @returns The email, or an `EmailRefusedError` for an unmapped name or props the schema refuses.
115
+ */
116
+ compose<Declaration extends AnyEmailDeclaration>(email: Declaration | string, delivery: EmailDelivery): Promise<Result<ComposedEmail<EmailPropsOutput<Declaration>>, MailError>>;
117
+ /**
118
+ * Renders a declared email without sending it, for a preview or a test asserting on copy.
119
+ *
120
+ * @param email - The declared email.
121
+ * @param input - The props and the locale to render with.
122
+ * @returns The subject and both body parts.
123
+ * @example let { subject, text } = unwrap(await mail.render(emails.welcome, { locale: "es", props }));
124
+ */
125
+ render<Declaration extends AnyEmailDeclaration>(email: Declaration, input: RenderInput<EmailPropsInput<Declaration>>): Promise<Result<RenderedDeclaredEmail, MailError>>;
126
+ /**
127
+ * Renders a declared email with its template's `preview()` props.
128
+ *
129
+ * @param email - The declared email, or its name as a preview URL carries it.
130
+ * @param locale - The language to render in; omitted means the translator's fallback.
131
+ * @returns The rendered email, or a `MailError` when the template has no `preview()`.
132
+ */
133
+ preview(email: AnyEmailDeclaration | string, locale?: string): Promise<Result<RenderedDeclaredEmail, MailError>>;
134
+ /**
135
+ * Reports whether the template mapped for an email has a `preview()`, loading it without
136
+ * rendering, so a preview list marks the emails it cannot show.
137
+ *
138
+ * @param email - The declared email, or its name.
139
+ * @returns `false` for an unmapped email or a template that failed to load, too.
140
+ */
141
+ hasPreview(email: AnyEmailDeclaration | string): Promise<boolean>;
142
+ /**
143
+ * Parses props with the declared schema without rendering anything, which is how a mailer
144
+ * refuses a queued delivery where its caller wrote it.
145
+ *
146
+ * @param email - The declared email.
147
+ * @param props - The props as the caller wrote them.
148
+ * @returns The parsed props, or an `EmailRefusedError` naming every issue.
149
+ */
150
+ validate<Declaration extends AnyEmailDeclaration>(email: Declaration, props: unknown): Promise<Result<EmailPropsOutput<Declaration>, MailError>>;
151
+ }
152
+ /**
153
+ * Creates the app's email registry.
154
+ *
155
+ * @param options - The translator; see {@link MailOptions}.
156
+ * @returns The instance an initializer maps every declared email onto.
157
+ * @example export const mail = createMail({ translator: translate });
158
+ */
159
+ export declare function createMail(options?: MailOptions): Mail;
160
+ /**
161
+ * Reports whether an email was composed from a declaration, and from which one when given,
162
+ * which is how a test picks a sent message out of a memory transport.
163
+ *
164
+ * @param email - The `email` a normalized message carries.
165
+ * @param declaration - The declared email it must come from.
166
+ * @returns `true` for an email `Mail.compose()` built, from `declaration` when one is given.
167
+ * @example transport.find((message) => isComposedEmail(message.email, emails.welcome));
168
+ */
169
+ export declare function isComposedEmail<Declaration extends AnyEmailDeclaration>(email: Email | undefined, declaration?: Declaration): email is ComposedEmail<EmailPropsOutput<Declaration>>;
package/dist/mail.js ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * `createMail()`: the app's email registry. It maps every declared email onto its template and
3
+ * composes one from its props and the reader's locale, so a send, a queued delivery and a
4
+ * preview all render the same way, wherever the mailer that sends it runs.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { failure, isFailure, success, wrap } from "@sdxc/result";
10
+ import { createElement } from "remix/component";
11
+ import * as s from "remix/data-schema";
12
+ import { isEmailDeclaration } from "./emails.js";
13
+ import { EmailRefusedError, MailError } from "./errors.js";
14
+ import { isLazy } from "./lazy.js";
15
+ import { render } from "./render.js";
16
+ /** A mailbox as it travels through a queue. */
17
+ const ADDRESS = s.object({ email: s.string(), name: s.optional(s.string()) });
18
+ /** One or several mailboxes, or a bare address string, as {@link EmailDelivery} accepts them. */
19
+ const ADDRESSES = s.union([s.string(), ADDRESS, s.array(ADDRESS)]);
20
+ /**
21
+ * The input of the job that delivers queued email: `job({ input: EMAIL_DELIVERY })`. The props
22
+ * pass through untouched here, since the declared email's own schema parses them on delivery.
23
+ */
24
+ export const EMAIL_DELIVERY = s.object({
25
+ email: s.string(),
26
+ to: ADDRESSES,
27
+ locale: s.optional(s.string()),
28
+ props: s.any(),
29
+ from: s.optional(ADDRESS),
30
+ cc: s.optional(ADDRESSES),
31
+ bcc: s.optional(ADDRESSES),
32
+ replyTo: s.optional(ADDRESSES),
33
+ headers: s.optional(s.record(s.string(), s.string())),
34
+ });
35
+ /** What `t` does for an app that configured no translator: hands back the key. */
36
+ function untranslated(key) {
37
+ return key;
38
+ }
39
+ /**
40
+ * The app's email registry: every declared email mapped onto its template. It holds no
41
+ * transport, so the same instance serves the Worker's mailers and a command run on Bun; a
42
+ * mailer configured with it as `mail` sends, defers and queues declared emails.
43
+ */
44
+ export class Mail {
45
+ /** The translator every composed email receives. */
46
+ #options;
47
+ /** Declared emails by name, each with the loader that resolves its template once. */
48
+ #mapped = new Map();
49
+ /**
50
+ * Creates the instance; {@link createMail} is the usual way to call it.
51
+ *
52
+ * @param options - The translator; see {@link MailOptions}.
53
+ */
54
+ constructor(options = {}) {
55
+ this.#options = options;
56
+ }
57
+ /** Every mapped email, in the order it was mapped, for a preview list or a check to walk. */
58
+ get mapped() {
59
+ return [...this.#mapped.values()].map((entry) => entry.declaration);
60
+ }
61
+ /**
62
+ * Maps a declared email onto its template, held directly or loaded through `lazy()` on the
63
+ * email's first send.
64
+ *
65
+ * @param email - The declared email.
66
+ * @param template - What `createEmail()` returned for it, or the module that default-exports it.
67
+ * @throws When the email is already mapped, or the template was written for another email.
68
+ * @example mail.map(emails.welcome, lazy(() => import("~/app/emails/welcome")));
69
+ */
70
+ map(email, template) {
71
+ if (this.#mapped.has(email.name))
72
+ throw new Error(`Email "${email.name}" is already mapped`);
73
+ this.#mapped.set(email.name, { declaration: email, template: resolver(email, template) });
74
+ }
75
+ /**
76
+ * Builds the email a delivery describes: the props parsed by the declared schema, the
77
+ * locale bound to the translator, and every template member read from both.
78
+ *
79
+ * @param email - The declared email, or its name as a queued delivery carries it.
80
+ * @param delivery - The recipient, the locale and the props.
81
+ * @returns The email, or an `EmailRefusedError` for an unmapped name or props the schema refuses.
82
+ */
83
+ async compose(email, delivery) {
84
+ let loaded = await this.#load(email);
85
+ if (isFailure(loaded))
86
+ return loaded;
87
+ let { declaration, template } = loaded.data;
88
+ let props = await parse(declaration, delivery.props);
89
+ if (isFailure(props))
90
+ return props;
91
+ let language = this.#translate(delivery.locale);
92
+ let input = { ...props.data, ...language };
93
+ let composed = wrap(() => build(declaration, template, delivery, props.data, input));
94
+ if (isFailure(composed)) {
95
+ return failure(new MailError(`Failed to compose the "${declaration.name}" email.`, {
96
+ cause: composed.error,
97
+ }));
98
+ }
99
+ return success(composed.data);
100
+ }
101
+ /**
102
+ * Renders a declared email without sending it, for a preview or a test asserting on copy.
103
+ *
104
+ * @param email - The declared email.
105
+ * @param input - The props and the locale to render with.
106
+ * @returns The subject and both body parts.
107
+ * @example let { subject, text } = unwrap(await mail.render(emails.welcome, { locale: "es", props }));
108
+ */
109
+ async render(email, input) {
110
+ let composed = await this.compose(email, { ...input, to: [] });
111
+ if (isFailure(composed))
112
+ return composed;
113
+ let rendered = await wrap(() => render(composed.data.body()));
114
+ if (isFailure(rendered)) {
115
+ return failure(new MailError("Failed to render the email body.", { cause: rendered.error }));
116
+ }
117
+ return success({ subject: composed.data.subject, ...rendered.data });
118
+ }
119
+ /**
120
+ * Renders a declared email with its template's `preview()` props.
121
+ *
122
+ * @param email - The declared email, or its name as a preview URL carries it.
123
+ * @param locale - The language to render in; omitted means the translator's fallback.
124
+ * @returns The rendered email, or a `MailError` when the template has no `preview()`.
125
+ */
126
+ async preview(email, locale) {
127
+ let loaded = await this.#load(email);
128
+ if (isFailure(loaded))
129
+ return loaded;
130
+ let { declaration, template } = loaded.data;
131
+ if (template.preview === undefined) {
132
+ return failure(new MailError(`The "${declaration.name}" email has no preview.`));
133
+ }
134
+ let props = wrap(() => template.preview?.());
135
+ if (isFailure(props)) {
136
+ return failure(new MailError(`The "${declaration.name}" preview threw.`, { cause: props.error }));
137
+ }
138
+ return await this.render(declaration, { locale, props: props.data });
139
+ }
140
+ /**
141
+ * Reports whether the template mapped for an email has a `preview()`, loading it without
142
+ * rendering, so a preview list marks the emails it cannot show.
143
+ *
144
+ * @param email - The declared email, or its name.
145
+ * @returns `false` for an unmapped email or a template that failed to load, too.
146
+ */
147
+ async hasPreview(email) {
148
+ let loaded = await this.#load(email);
149
+ return !isFailure(loaded) && loaded.data.template.preview !== undefined;
150
+ }
151
+ /**
152
+ * Parses props with the declared schema without rendering anything, which is how a mailer
153
+ * refuses a queued delivery where its caller wrote it.
154
+ *
155
+ * @param email - The declared email.
156
+ * @param props - The props as the caller wrote them.
157
+ * @returns The parsed props, or an `EmailRefusedError` naming every issue.
158
+ */
159
+ async validate(email, props) {
160
+ return (await parse(email, props));
161
+ }
162
+ /** Resolves a declaration or a name to the mapped declaration and its loaded template. */
163
+ async #load(email) {
164
+ let name = isEmailDeclaration(email) ? email.name : email;
165
+ let entry = this.#mapped.get(name);
166
+ if (entry === undefined) {
167
+ return failure(new EmailRefusedError(`No template is mapped for the "${name}" email.`));
168
+ }
169
+ let template = await wrap(() => entry.template());
170
+ if (isFailure(template)) {
171
+ return failure(new MailError(`Failed to load the "${name}" email's template.`, {
172
+ cause: template.error,
173
+ }));
174
+ }
175
+ return success({ declaration: entry.declaration, template: template.data });
176
+ }
177
+ /** The reader's language and translator, or the key-returning stand-in without a translator. */
178
+ #translate(locale) {
179
+ let translator = this.#options.translator;
180
+ if (translator !== undefined)
181
+ return translator(locale);
182
+ return { locale: locale ?? "en", t: untranslated };
183
+ }
184
+ }
185
+ /**
186
+ * Creates the app's email registry.
187
+ *
188
+ * @param options - The translator; see {@link MailOptions}.
189
+ * @returns The instance an initializer maps every declared email onto.
190
+ * @example export const mail = createMail({ translator: translate });
191
+ */
192
+ export function createMail(options = {}) {
193
+ return new Mail(options);
194
+ }
195
+ /**
196
+ * A function resolving a binding to its template, importing a lazy one at most once per
197
+ * isolate. A failed import is dropped, so the next send retries it.
198
+ */
199
+ function resolver(email, template) {
200
+ if (!isLazy(template)) {
201
+ check(email, template);
202
+ return () => Promise.resolve(template);
203
+ }
204
+ let loading;
205
+ return () => {
206
+ loading ??= template.load().then((module) => check(email, module.default), (error) => {
207
+ loading = undefined;
208
+ throw error;
209
+ });
210
+ return loading;
211
+ };
212
+ }
213
+ /**
214
+ * Confirms a template renders the email it is mapped onto, so a module mapped under the wrong
215
+ * name fails on its first load instead of sending the wrong copy.
216
+ */
217
+ function check(email, template) {
218
+ if (template.email.name !== email.name) {
219
+ throw new Error(`The template mapped for "${email.name}" was written for "${template.email.name}"`);
220
+ }
221
+ return template;
222
+ }
223
+ /** Parses props with the declared schema, refusing them with every issue's message. */
224
+ async function parse(email, props) {
225
+ let result = await email.props["~standard"].validate(props);
226
+ if (result.issues === undefined)
227
+ return success(result.value);
228
+ let issues = result.issues.map((issue) => {
229
+ let path = issue.path?.map((key) => (typeof key === "object" ? key.key : key)).join(".");
230
+ return path ? `${path}: ${issue.message}` : issue.message;
231
+ });
232
+ return failure(new EmailRefusedError(`Invalid props for the "${email.name}" email: ${issues.join("; ")}`));
233
+ }
234
+ /** The email one template reads from `input`, with the delivery's reply-to and headers on top. */
235
+ function build(declaration, template, delivery, props, input) {
236
+ let headers = template.headers?.(input);
237
+ return {
238
+ declaration,
239
+ props,
240
+ locale: input.locale,
241
+ to: typeof delivery.to === "string" ? { email: delivery.to } : delivery.to,
242
+ subject: template.subject(input),
243
+ replyTo: delivery.replyTo ?? template.replyTo?.(input),
244
+ headers: delivery.headers ? { ...headers, ...delivery.headers } : headers,
245
+ unsubscribe: template.unsubscribe?.(input),
246
+ list: template.list?.(input),
247
+ calendar: template.calendar?.(input),
248
+ body: () => createElement(template.body, input),
249
+ };
250
+ }
251
+ /**
252
+ * Reports whether an email was composed from a declaration, and from which one when given,
253
+ * which is how a test picks a sent message out of a memory transport.
254
+ *
255
+ * @param email - The `email` a normalized message carries.
256
+ * @param declaration - The declared email it must come from.
257
+ * @returns `true` for an email `Mail.compose()` built, from `declaration` when one is given.
258
+ * @example transport.find((message) => isComposedEmail(message.email, emails.welcome));
259
+ */
260
+ export function isComposedEmail(email, declaration) {
261
+ if (email === undefined || !("declaration" in email))
262
+ return false;
263
+ let composed = email;
264
+ return declaration === undefined || composed.declaration.name === declaration.name;
265
+ }
package/dist/mailer.d.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  * @copyright Sergio Xalambrí 2026
10
10
  */
11
11
  import type { Result } from "@sdxc/result";
12
+ import type { AnyEmailDeclaration } from "./emails.js";
13
+ import type { DeliveryFor, Mail, QueuedEmail } from "./mail.js";
12
14
  import type { Address, Email, Message, SentMessage, Transport } from "./types.js";
13
15
  import { MailError } from "./errors.js";
14
16
  /**
@@ -25,6 +27,16 @@ export interface MailerOptions {
25
27
  replyTo?: Address | Address[];
26
28
  /** Headers added to every message, with per-message headers winning. */
27
29
  headers?: Record<string, string>;
30
+ /**
31
+ * The app's `createMail()` instance, which resolves a declared email to its template; what
32
+ * lets `send()`, `later()` and `queue()` take a declaration.
33
+ */
34
+ mail?: Mail;
35
+ /**
36
+ * Hands a declared email's delivery to the app's queue, which is what `queue()` calls; the
37
+ * job that receives it sends it with `ctx.email.deliver(ctx.input)`.
38
+ */
39
+ queue?: (delivery: QueuedEmail) => Promise<void>;
28
40
  }
29
41
  /**
30
42
  * Reports whether a value is an `Email` rather than a plain `Message`.
@@ -55,22 +67,47 @@ export declare class Mailer {
55
67
  * render failure, an invalid message, a rejected delivery, and a transport
56
68
  * that throws all arrive as a `MailError` failure for the caller to inspect.
57
69
  *
58
- * @param input - A plain message, or an email object that renders its own body.
59
- * @param overrides - Fields that replace what the input provides for this send.
70
+ * @param input - A declared email, a plain message, or an email object that renders its own body.
71
+ * @param overrides - For a declared email, the delivery: recipient, locale and props; otherwise
72
+ * fields that replace what the input provides for this send.
60
73
  * @returns The provider's identifier on success, a `MailError` on failure.
61
- * @example let result = await mailer.send(new TeamInviteEmail(invite));
74
+ * @example let result = await ctx.email.send(emails.teams.invite, { to, locale, props: { team, url } });
62
75
  */
76
+ send<Declaration extends AnyEmailDeclaration>(input: Declaration, delivery: DeliveryFor<Declaration>): Promise<Result<SentMessage, MailError>>;
63
77
  send(input: Message | Email, overrides?: SendOptions): Promise<Result<SentMessage, MailError>>;
64
78
  /**
65
79
  * Queues a message to be sent once the caller flushes, which is how a send whose
66
80
  * failure must not affect the response gets a defined lifetime. Nothing is
67
81
  * rendered or validated yet; that happens at flush time.
68
82
  *
69
- * @param input - A plain message, or an email object that renders its own body.
70
- * @param overrides - Fields that replace what the input provides for this send.
71
- * @example ctx.email.later(new PasswordResetEmail(user));
83
+ * @param input - A declared email, a plain message, or an email object that renders its own body.
84
+ * @param overrides - For a declared email, the delivery; otherwise fields that replace what
85
+ * the input provides for this send.
86
+ * @example ctx.email.later(emails.passwordReset, { to: user.email, locale, props: { url } });
72
87
  */
88
+ later<Declaration extends AnyEmailDeclaration>(input: Declaration, delivery: DeliveryFor<Declaration>): void;
73
89
  later(input: Message | Email, overrides?: SendOptions): void;
90
+ /**
91
+ * Hands a declared email to the app's queue, so a job sends it with retries on its own
92
+ * schedule. The props are validated here, so a caller learns about bad props where it wrote
93
+ * them; they render, translated, when the job delivers them.
94
+ *
95
+ * @param email - The declared email.
96
+ * @param delivery - The recipient, the locale and the props, all plain JSON.
97
+ * @returns Success once the queue accepted the delivery; an `EmailRefusedError` for props the
98
+ * schema refuses, or a `MailError` when no queue is configured or it threw.
99
+ * @example await ctx.email.queue(emails.teams.invite, { to, locale, props: { team, url } });
100
+ */
101
+ queue<Declaration extends AnyEmailDeclaration>(email: Declaration, delivery: DeliveryFor<Declaration>): Promise<Result<void, MailError>>;
102
+ /**
103
+ * Sends a delivery `queue()` handed to the app's queue, which is the whole of the job that
104
+ * receives it. An `EmailRefusedError` means no retry can send it; any other failure can.
105
+ *
106
+ * @param queued - The delivery as the job received it.
107
+ * @returns The provider's identifier on success, a `MailError` on failure.
108
+ * @example let sent = await ctx.email.deliver(ctx.input);
109
+ */
110
+ deliver(queued: QueuedEmail): Promise<Result<SentMessage, MailError>>;
74
111
  /**
75
112
  * Sends everything `later()` queued and empties the queue. Results come back in
76
113
  * queue order for the caller to log; like `send()`, it never throws, so a failed