@sdxc/mail 2026.10.13 → 2026.10.16

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
@@ -259,6 +259,15 @@ Records every delivery instead of sending it. `options.mime: true` also records
259
259
  message. Members: `messages`, `last`, `deliveries` (`RecordedDelivery[]`, each
260
260
  `{ message, mime? }`), `lastMime`, `find(predicate)` and `clear()`.
261
261
 
262
+ ### `@sdxc/mail/print`
263
+
264
+ #### `new PrintTransport(options: PrintTransportOptions)`
265
+
266
+ Writes each message to `options.write` instead of sending it, and reports it sent: the envelope,
267
+ subject, date, `Message-ID` and extra headers, then the plain-text body. `options.mime: true`
268
+ writes the raw MIME message instead. It writes only through the function it is given, so it runs
269
+ anywhere: `ctx.stderr` in a command, `(text) => process.stderr.write(text)` in a script.
270
+
262
271
  ### `@sdxc/mail/cloudflare`
263
272
 
264
273
  #### `new CloudflareTransport(binding: SendEmailBinding)`
@@ -277,7 +286,36 @@ binding.
277
286
  Publishes a request-scoped `Mailer` as `context.email` and flushes its queue after `next()`.
278
287
  `options` takes `transport` (a `Transport`, or `(context) => Transport` per request), `from`,
279
288
  `replyTo?` and `headers?`. Each deferred send logs `mail.sent` or a `mail.send_failed` warning
280
- on the current log.
289
+ on the current log. It publishes under the `CurrentMailer` context key, exported here and from
290
+ `./middleware/cli`, so `ctx.get(CurrentMailer)` reads the same mailer in a route or a command.
291
+
292
+ ### `@sdxc/mail/middleware/cli`
293
+
294
+ #### `mail(options: MailCommandOptions): CommandMiddleware` (default export)
295
+
296
+ Publishes a `Mailer` as `ctx.email` on an [`@sdxc/cli`](https://www.npmjs.com/package/@sdxc/cli)
297
+ command, so an admin command resends a message with the templates a route uses. `options` takes
298
+ the router entry's fields, with `transport` a `Transport` or `(ctx) => Transport` per run. The
299
+ queue flushes after the handler and each send is logged as in the router entry; a failed deferred
300
+ send also fails the run with its error. On a laptop, pass a `PrintTransport` writing to the
301
+ command's stderr, so a local run shows each message and never emails a real user.
302
+
303
+ ```typescript
304
+ import { createCLI } from "@sdxc/cli";
305
+ import mail from "@sdxc/mail/middleware/cli";
306
+ import { PrintTransport } from "@sdxc/mail/print";
307
+
308
+ let cli = createCLI({
309
+ name: "app",
310
+ commands,
311
+ middleware: [
312
+ mail({
313
+ transport: (ctx) => new PrintTransport({ write: (text) => ctx.stderr(text) }),
314
+ from: { email: "no-reply@example.com" },
315
+ }),
316
+ ],
317
+ });
318
+ ```
281
319
 
282
320
  ### `@sdxc/mail/unsubscribe`
283
321
 
package/dist/cli.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `@sdxc/cli` middleware that publishes the app's mailer to a command as `ctx.email`, so an admin
3
+ * command resends an invitation or a receipt with the same templates a route uses, through
4
+ * whichever transport the app passes for where the command runs.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { AnyCommandContext, CommandMiddleware } from "@sdxc/cli";
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 command-line mail middleware. */
16
+ export interface MailCommandOptions extends Omit<MailerOptions, "transport"> {
17
+ /**
18
+ * Transport used for delivery, or a factory that resolves one per run from the command's
19
+ * context, such as one that reads `ctx.flags.remote` to pick the production transport.
20
+ */
21
+ transport: Transport | ((ctx: AnyCommandContext) => Transport);
22
+ }
23
+ /**
24
+ * Publishes a mailer as `ctx.email` for the run. Messages queued with `later()` send once the
25
+ * handler returns, each outcome is recorded on the run's log, and a failed deferred send fails
26
+ * the run, so whoever ran the command sees that a message never left.
27
+ *
28
+ * @param options - Transport and sender configuration; see {@link MailCommandOptions}.
29
+ * @returns A middleware that installs `ctx.email`.
30
+ * @example createCLI({ name: "app", commands, middleware: [mail({ transport: new MemoryTransport(), from: SENDER })] });
31
+ */
32
+ export default function mail(options: MailCommandOptions): CommandMiddleware<{
33
+ key: typeof CurrentMailer;
34
+ value: Mailer;
35
+ property: "email";
36
+ }>;
package/dist/cli.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `@sdxc/cli` middleware that publishes the app's mailer to a command as `ctx.email`, so an admin
3
+ * command resends an invitation or a receipt with the same templates a route uses, through
4
+ * whichever transport the app passes for where the command runs.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { currentLog } from "@sdxc/logger";
10
+ import { failure, 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 the run. Messages queued with `later()` send once the
16
+ * handler returns, each outcome is recorded on the run's log, and a failed deferred send fails
17
+ * the run, so whoever ran the command sees that a message never left.
18
+ *
19
+ * @param options - Transport and sender configuration; see {@link MailCommandOptions}.
20
+ * @returns A middleware that installs `ctx.email`.
21
+ * @example createCLI({ name: "app", commands, middleware: [mail({ transport: new MemoryTransport(), from: SENDER })] });
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({
27
+ transport,
28
+ from: options.from,
29
+ replyTo: options.replyTo,
30
+ headers: options.headers,
31
+ });
32
+ ctx.set(CurrentMailer, mailer, { property: "email" });
33
+ let failed;
34
+ try {
35
+ await next();
36
+ }
37
+ finally {
38
+ failed = await flush(mailer);
39
+ }
40
+ if (failed !== undefined)
41
+ return failure(failed);
42
+ };
43
+ }
44
+ /**
45
+ * Sends the mailer's deferred queue, recording each outcome on the run's log.
46
+ *
47
+ * @returns The first send's error, or `undefined` when every message left.
48
+ */
49
+ async function flush(mailer) {
50
+ let log = currentLog();
51
+ let failed;
52
+ for (let result of await mailer.flush()) {
53
+ if (isFailure(result)) {
54
+ log?.warn("mail.send_failed", { error: result.error.message });
55
+ failed ??= result.error;
56
+ }
57
+ else {
58
+ log?.set({ mail: { sent: true } }).note("mail.sent", { message_id: result.data.messageId });
59
+ }
60
+ }
61
+ return failed;
62
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The context key the router and command-line middleware publish the mailer under, so code that
3
+ * reads it by key sends through the same mailer whichever host it runs in.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ import type { Mailer } from "./mailer.js";
9
+ /** The mailer the current invocation sends through, installed as `ctx.email`. */
10
+ export declare const CurrentMailer: {
11
+ defaultValue?: Mailer;
12
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The context key the router and command-line middleware publish the mailer under, so code that
3
+ * reads it by key sends through the same mailer whichever host it runs in.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ import { createContextKey } from "remix/router";
9
+ /** The mailer the current invocation sends through, installed as `ctx.email`. */
10
+ export const CurrentMailer = createContextKey();
@@ -11,6 +11,7 @@
11
11
  import type { Middleware, RequestContext } from "remix/router";
12
12
  import type { Address, Transport } from "./types.js";
13
13
  import { Mailer } from "./mailer.js";
14
+ export { CurrentMailer } from "./context.js";
14
15
  /**
15
16
  * Declared here, in an imported module rather than an ambient .d.ts, so the
16
17
  * augmentation is applied in consuming projects that import the middleware.
@@ -40,10 +41,9 @@ export interface MailMiddlewareOptions {
40
41
  headers?: Record<string, string>;
41
42
  }
42
43
  /**
43
- * Creates a middleware that publishes a request-scoped mailer as `context.email`,
44
- * resolving a container-based transport once per request. The `later()` queue
45
- * flushes in a `finally` after `next()`; the response is already decided by then,
46
- * so a failed deferred send is a warning on the invocation's log rather than an error.
44
+ * Publishes a request-scoped mailer as `context.email`, under the {@link CurrentMailer} key the
45
+ * command-line entry shares. The `later()` queue flushes after `next()`, when the response is
46
+ * already decided, so a failed deferred send is a warning on the invocation's log.
47
47
  *
48
48
  * @param options - Transport and sender configuration; see {@link MailMiddlewareOptions}.
49
49
  * @returns A middleware that populates `context.email`.
@@ -10,12 +10,13 @@
10
10
  */
11
11
  import { currentLog } from "@sdxc/logger";
12
12
  import { isFailure } from "@sdxc/result";
13
+ import { CurrentMailer } from "./context.js";
13
14
  import { Mailer } from "./mailer.js";
15
+ export { CurrentMailer } from "./context.js";
14
16
  /**
15
- * Creates a middleware that publishes a request-scoped mailer as `context.email`,
16
- * resolving a container-based transport once per request. The `later()` queue
17
- * flushes in a `finally` after `next()`; the response is already decided by then,
18
- * so a failed deferred send is a warning on the invocation's log rather than an error.
17
+ * Publishes a request-scoped mailer as `context.email`, under the {@link CurrentMailer} key the
18
+ * command-line entry shares. The `later()` queue flushes after `next()`, when the response is
19
+ * already decided, so a failed deferred send is a warning on the invocation's log.
19
20
  *
20
21
  * @param options - Transport and sender configuration; see {@link MailMiddlewareOptions}.
21
22
  * @returns A middleware that populates `context.email`.
@@ -31,7 +32,7 @@ export default function mail(options) {
31
32
  replyTo: options.replyTo,
32
33
  headers: options.headers,
33
34
  });
34
- context.email = mailer;
35
+ context.set(CurrentMailer, mailer, { property: "email" });
35
36
  try {
36
37
  return await next();
37
38
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Transport that prints each message instead of delivering it, for development and command
3
+ * lines run against local data: the message shows where the developer is looking and never
4
+ * reaches a real inbox. It writes through a function it is given, so it runs on any runtime.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { MailError } from "./errors.js";
11
+ import type { NormalizedMessage, SentMessage, Transport } from "./types.js";
12
+ /** Where a print transport writes, and in which form. */
13
+ export interface PrintTransportOptions {
14
+ /**
15
+ * Receives each printed message as one string ending in a newline, such as `ctx.stderr`
16
+ * in a command or `(text) => process.stderr.write(text)` in a script.
17
+ */
18
+ write: (text: string) => void;
19
+ /**
20
+ * Print the raw MIME message, headers and every part, instead of the readable summary.
21
+ * @default false
22
+ */
23
+ mime?: boolean;
24
+ }
25
+ /**
26
+ * Transport that writes each message to `options.write` and reports it sent. The summary lists
27
+ * the envelope, the subject, the date, the `Message-ID` and any extra headers, then the
28
+ * plain-text body (the HTML one when a message has no text).
29
+ *
30
+ * @example new Mailer({ transport: new PrintTransport({ write: (text) => ctx.stderr(text) }), from: SENDER });
31
+ * @example new PrintTransport({ write: (text) => process.stderr.write(text), mime: true });
32
+ */
33
+ export declare class PrintTransport implements Transport {
34
+ #private;
35
+ /**
36
+ * Creates the transport.
37
+ *
38
+ * @param options - Where to write and in which form; see {@link PrintTransportOptions}.
39
+ */
40
+ constructor(options: PrintTransportOptions);
41
+ /**
42
+ * Prints the message and reports success.
43
+ *
44
+ * @param message - The normalized message to print.
45
+ * @returns Success carrying the message's own `Message-ID`, since no provider assigned one.
46
+ */
47
+ send(message: NormalizedMessage): Promise<Result<SentMessage, MailError>>;
48
+ }
package/dist/print.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Transport that prints each message instead of delivering it, for development and command
3
+ * lines run against local data: the message shows where the developer is looking and never
4
+ * reaches a real inbox. It writes through a function it is given, so it runs on any runtime.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { success } from "@sdxc/result";
10
+ import { buildMimeMessage } from "./mime.js";
11
+ /**
12
+ * Transport that writes each message to `options.write` and reports it sent. The summary lists
13
+ * the envelope, the subject, the date, the `Message-ID` and any extra headers, then the
14
+ * plain-text body (the HTML one when a message has no text).
15
+ *
16
+ * @example new Mailer({ transport: new PrintTransport({ write: (text) => ctx.stderr(text) }), from: SENDER });
17
+ * @example new PrintTransport({ write: (text) => process.stderr.write(text), mime: true });
18
+ */
19
+ export class PrintTransport {
20
+ /** The function each printed message goes to. */
21
+ #write;
22
+ /** Whether messages print as raw MIME. */
23
+ #mime;
24
+ /**
25
+ * Creates the transport.
26
+ *
27
+ * @param options - Where to write and in which form; see {@link PrintTransportOptions}.
28
+ */
29
+ constructor(options) {
30
+ this.#write = options.write;
31
+ this.#mime = options.mime ?? false;
32
+ }
33
+ /**
34
+ * Prints the message and reports success.
35
+ *
36
+ * @param message - The normalized message to print.
37
+ * @returns Success carrying the message's own `Message-ID`, since no provider assigned one.
38
+ */
39
+ async send(message) {
40
+ this.#write(this.#mime ? ensureNewline(buildMimeMessage(message)) : summarize(message));
41
+ return success({ messageId: message.messageId });
42
+ }
43
+ }
44
+ /** The readable form of a message: one header per line, a blank line, then the body. */
45
+ function summarize(message) {
46
+ let lines = [`From: ${formatAddresses([message.from])}`, `To: ${formatAddresses(message.to)}`];
47
+ if (message.cc.length > 0)
48
+ lines.push(`Cc: ${formatAddresses(message.cc)}`);
49
+ if (message.bcc.length > 0)
50
+ lines.push(`Bcc: ${formatAddresses(message.bcc)}`);
51
+ if (message.replyTo.length > 0)
52
+ lines.push(`Reply-To: ${formatAddresses(message.replyTo)}`);
53
+ lines.push(`Subject: ${message.subject}`);
54
+ lines.push(`Date: ${message.date.toUTCString()}`);
55
+ lines.push(`Message-ID: ${message.messageId}`);
56
+ for (let [name, value] of Object.entries(message.headers))
57
+ lines.push(`${name}: ${value}`);
58
+ let body = message.text ?? message.html ?? "";
59
+ return `${lines.join("\n")}\n\n${ensureNewline(body)}`;
60
+ }
61
+ /** Mailboxes as a mail client shows them: `Name <email>`, or the bare address without a name. */
62
+ function formatAddresses(addresses) {
63
+ return addresses
64
+ .map((address) => address.name === undefined || address.name === ""
65
+ ? address.email
66
+ : `${address.name} <${address.email}>`)
67
+ .join(", ");
68
+ }
69
+ /** `text` ending in exactly one newline, so consecutive messages never run together. */
70
+ function ensureNewline(text) {
71
+ return text.endsWith("\n") ? text : `${text}\n`;
72
+ }
package/package.json CHANGED
@@ -1,27 +1,37 @@
1
1
  {
2
2
  "name": "@sdxc/mail",
3
- "version": "2026.10.13",
3
+ "version": "2026.10.16",
4
4
  "description": "Transport-agnostic transactional email with pluggable transports and remix/component rendering",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./dist/index.js",
9
+ "./cloudflare": "./dist/cloudflare.js",
9
10
  "./markdown": "./dist/markdown.js",
10
11
  "./memory": "./dist/memory.js",
12
+ "./print": "./dist/print.js",
11
13
  "./middleware": "./dist/middleware.js",
12
- "./cloudflare": "./dist/cloudflare.js",
14
+ "./middleware/cli": "./dist/cli.js",
13
15
  "./unsubscribe": "./dist/unsubscribe.js"
14
16
  },
15
17
  "dependencies": {
16
18
  "@sdxc/crypto": "2026.10.13",
17
- "@sdxc/highlight": "2026.10.13",
18
- "@sdxc/logger": "2026.10.13",
19
- "@sdxc/markdown": "2026.10.13",
19
+ "@sdxc/highlight": "2026.10.16",
20
+ "@sdxc/logger": "2026.10.16",
21
+ "@sdxc/markdown": "2026.10.16",
20
22
  "@sdxc/result": "2026.10.13",
21
- "@sdxc/uuid": "2026.10.13",
23
+ "@sdxc/uuid": "2026.10.13"
24
+ },
25
+ "peerDependencies": {
26
+ "@sdxc/cli": "2026.10.16",
22
27
  "remix": "3.0.0"
23
28
  },
24
- "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
29
+ "peerDependenciesMeta": {
30
+ "@sdxc/cli": {
31
+ "optional": true
32
+ }
33
+ },
34
+ "gitHead": "1e547d8431568f5ed9bdb079ca3046c936240fde",
25
35
  "publishConfig": {
26
36
  "access": "public"
27
37
  },
@@ -30,7 +40,7 @@
30
40
  "url": "git+https://github.com/sergiodxa/monorepo.git",
31
41
  "directory": "packages/mail"
32
42
  },
33
- "homepage": "https://sdxc.sergiodxa.com/api/mail",
43
+ "homepage": "https://sdxc.dev/api/mail",
34
44
  "author": {
35
45
  "name": "Sergio Xalambrí",
36
46
  "url": "https://sergiodxa.com"