@sdxc/mail 2026.10.13 → 2026.10.17
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 +79 -68
- package/dist/cli.d.ts +36 -0
- package/dist/cli.js +62 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +10 -0
- package/dist/define.d.ts +61 -0
- package/dist/define.js +48 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/mailer.d.ts +1 -1
- package/dist/mailer.js +1 -1
- package/dist/memory.d.ts +1 -1
- package/dist/memory.js +1 -1
- package/dist/middleware.d.ts +4 -4
- package/dist/middleware.js +6 -5
- package/dist/print.d.ts +48 -0
- package/dist/print.js +72 -0
- package/dist/types.d.ts +2 -2
- package/package.json +18 -8
package/README.md
CHANGED
|
@@ -44,46 +44,36 @@ if (isFailure(result)) console.warn(result.error.message);
|
|
|
44
44
|
|
|
45
45
|
The plain-text part is derived from the HTML, so every message ships both.
|
|
46
46
|
|
|
47
|
-
###
|
|
47
|
+
### Define an email
|
|
48
48
|
|
|
49
49
|
```tsx
|
|
50
|
-
import
|
|
50
|
+
import { defineEmail, Email } from "@sdxc/mail";
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
interface TeamInvite {
|
|
53
|
+
team: string;
|
|
54
|
+
url: string;
|
|
55
|
+
}
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
export const TeamInviteEmail = defineEmail<TeamInvite>({
|
|
58
|
+
subject: (invite) => `Join ${invite.team}`,
|
|
59
|
+
body: (handle) => () => (
|
|
56
60
|
<Email.Layout preview={`Join ${handle.props.team}`} title="Invite">
|
|
57
61
|
<Email.Heading>You have been invited</Email.Heading>
|
|
58
62
|
<Email.Text>Join {handle.props.team} to start collaborating.</Email.Text>
|
|
59
63
|
<Email.Button href={handle.props.url}>Accept invite</Email.Button>
|
|
60
64
|
<Email.Footer>You received this because someone invited you to a team.</Email.Footer>
|
|
61
65
|
</Email.Layout>
|
|
62
|
-
)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export class TeamInviteEmail implements Email {
|
|
66
|
-
constructor(private invite: { team: string; email: string; url: string; subject: string }) {}
|
|
67
|
-
|
|
68
|
-
get to() {
|
|
69
|
-
return { email: this.invite.email };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
get subject() {
|
|
73
|
-
return this.invite.subject;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
body() {
|
|
77
|
-
return <InviteBody team={this.invite.team} url={this.invite.url} />;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
66
|
+
),
|
|
67
|
+
});
|
|
80
68
|
|
|
81
|
-
await mailer.send(new TeamInviteEmail(invite));
|
|
69
|
+
await mailer.send(new TeamInviteEmail(invite.email, invite));
|
|
82
70
|
```
|
|
83
71
|
|
|
84
|
-
`
|
|
85
|
-
`
|
|
86
|
-
recipient's language.
|
|
72
|
+
`defineEmail()` returns a class built with the recipient (a string, an `Address` or a list) and
|
|
73
|
+
the props; `replyTo`, `headers`, `unsubscribe`, `list` and `calendar` are optional functions of
|
|
74
|
+
the props. The subject arrives as a string, already in the recipient's language. Any object
|
|
75
|
+
with `to`, `subject` and `body()` also satisfies the `Email` contract, for an email whose
|
|
76
|
+
addressing needs more than its props.
|
|
87
77
|
|
|
88
78
|
### Publish a mailer on the request context
|
|
89
79
|
|
|
@@ -103,7 +93,7 @@ let router = createRouter({
|
|
|
103
93
|
});
|
|
104
94
|
|
|
105
95
|
router.post("/invites", async (context) => {
|
|
106
|
-
context.email.later(new TeamInviteEmail(invite));
|
|
96
|
+
context.email.later(new TeamInviteEmail(invite.email, invite));
|
|
107
97
|
return new Response(null, { status: 204 });
|
|
108
98
|
});
|
|
109
99
|
```
|
|
@@ -120,7 +110,7 @@ import { MemoryTransport } from "@sdxc/mail/memory";
|
|
|
120
110
|
let transport = new MemoryTransport();
|
|
121
111
|
let mailer = new Mailer({ transport, from: { email: "no-reply@example.com" } });
|
|
122
112
|
|
|
123
|
-
await mailer.send(new TeamInviteEmail(invite));
|
|
113
|
+
await mailer.send(new TeamInviteEmail(invite.email, invite));
|
|
124
114
|
|
|
125
115
|
expect(transport.messages).toHaveLength(1);
|
|
126
116
|
expect(transport.last?.email).toBeInstanceOf(TeamInviteEmail);
|
|
@@ -169,6 +159,11 @@ headers fold at 78 characters, non-ASCII names and subjects become RFC 2047 enco
|
|
|
169
159
|
part is quoted-printable or base64, whichever is smaller, line endings are CRLF, and `Bcc` is
|
|
170
160
|
left to the envelope.
|
|
171
161
|
|
|
162
|
+
### `defineEmail<Props>(definition: EmailDefinition<Props>): EmailClass<Props>`
|
|
163
|
+
|
|
164
|
+
Builds an email class from `subject(props)` and a `body` component. Each call returns its own
|
|
165
|
+
class, so `instanceof` tells two emails apart; an instance exposes the `props` it was built with.
|
|
166
|
+
|
|
172
167
|
### `isEmail(value: Message | Email): value is Email`
|
|
173
168
|
|
|
174
169
|
`true` when the value has a callable `body`, the one member only an `Email` has.
|
|
@@ -259,6 +254,15 @@ Records every delivery instead of sending it. `options.mime: true` also records
|
|
|
259
254
|
message. Members: `messages`, `last`, `deliveries` (`RecordedDelivery[]`, each
|
|
260
255
|
`{ message, mime? }`), `lastMime`, `find(predicate)` and `clear()`.
|
|
261
256
|
|
|
257
|
+
### `@sdxc/mail/print`
|
|
258
|
+
|
|
259
|
+
#### `new PrintTransport(options: PrintTransportOptions)`
|
|
260
|
+
|
|
261
|
+
Writes each message to `options.write` instead of sending it, and reports it sent: the envelope,
|
|
262
|
+
subject, date, `Message-ID` and extra headers, then the plain-text body. `options.mime: true`
|
|
263
|
+
writes the raw MIME message instead. It writes only through the function it is given, so it runs
|
|
264
|
+
anywhere: `ctx.stderr` in a command, `(text) => process.stderr.write(text)` in a script.
|
|
265
|
+
|
|
262
266
|
### `@sdxc/mail/cloudflare`
|
|
263
267
|
|
|
264
268
|
#### `new CloudflareTransport(binding: SendEmailBinding)`
|
|
@@ -277,7 +281,36 @@ binding.
|
|
|
277
281
|
Publishes a request-scoped `Mailer` as `context.email` and flushes its queue after `next()`.
|
|
278
282
|
`options` takes `transport` (a `Transport`, or `(context) => Transport` per request), `from`,
|
|
279
283
|
`replyTo?` and `headers?`. Each deferred send logs `mail.sent` or a `mail.send_failed` warning
|
|
280
|
-
on the current log.
|
|
284
|
+
on the current log. It publishes under the `CurrentMailer` context key, exported here and from
|
|
285
|
+
`./middleware/cli`, so `ctx.get(CurrentMailer)` reads the same mailer in a route or a command.
|
|
286
|
+
|
|
287
|
+
### `@sdxc/mail/middleware/cli`
|
|
288
|
+
|
|
289
|
+
#### `mail(options: MailCommandOptions): CommandMiddleware` (default export)
|
|
290
|
+
|
|
291
|
+
Publishes a `Mailer` as `ctx.email` on an [`@sdxc/cli`](https://www.npmjs.com/package/@sdxc/cli)
|
|
292
|
+
command, so an admin command resends a message with the templates a route uses. `options` takes
|
|
293
|
+
the router entry's fields, with `transport` a `Transport` or `(ctx) => Transport` per run. The
|
|
294
|
+
queue flushes after the handler and each send is logged as in the router entry; a failed deferred
|
|
295
|
+
send also fails the run with its error. On a laptop, pass a `PrintTransport` writing to the
|
|
296
|
+
command's stderr, so a local run shows each message and never emails a real user.
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
import { createCLI } from "@sdxc/cli";
|
|
300
|
+
import mail from "@sdxc/mail/middleware/cli";
|
|
301
|
+
import { PrintTransport } from "@sdxc/mail/print";
|
|
302
|
+
|
|
303
|
+
let cli = createCLI({
|
|
304
|
+
name: "app",
|
|
305
|
+
commands,
|
|
306
|
+
middleware: [
|
|
307
|
+
mail({
|
|
308
|
+
transport: (ctx) => new PrintTransport({ write: (text) => ctx.stderr(text) }),
|
|
309
|
+
from: { email: "no-reply@example.com" },
|
|
310
|
+
}),
|
|
311
|
+
],
|
|
312
|
+
});
|
|
313
|
+
```
|
|
281
314
|
|
|
282
315
|
### `@sdxc/mail/unsubscribe`
|
|
283
316
|
|
|
@@ -313,7 +346,7 @@ router.post("/password-reset", async (context) => {
|
|
|
313
346
|
});
|
|
314
347
|
|
|
315
348
|
router.post("/invites", async (context) => {
|
|
316
|
-
context.email.later(new TeamInviteEmail(invite)); // sent after the response, failures logged
|
|
349
|
+
context.email.later(new TeamInviteEmail(invite.email, invite)); // sent after the response, failures logged
|
|
317
350
|
return new Response(null, { status: 204 });
|
|
318
351
|
});
|
|
319
352
|
```
|
|
@@ -326,48 +359,26 @@ Gmail and Yahoo require one-click unsubscribe on subscribed and marketing mail.
|
|
|
326
359
|
its target and list, and the mailer writes the headers:
|
|
327
360
|
|
|
328
361
|
```tsx
|
|
329
|
-
import
|
|
330
|
-
|
|
331
|
-
import { Email } from "@sdxc/mail";
|
|
362
|
+
import { defineEmail, Email } from "@sdxc/mail";
|
|
332
363
|
import { signUnsubscribeToken } from "@sdxc/mail/unsubscribe";
|
|
333
364
|
import { unwrap } from "@sdxc/result";
|
|
334
365
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
get unsubscribe(): Unsubscribe {
|
|
350
|
-
return {
|
|
351
|
-
url: `https://example.com/unsubscribe/${this.token}`,
|
|
352
|
-
mailto: "unsubscribe@example.com",
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
get list(): MailingList {
|
|
357
|
-
return { id: "daily-digest.example.com", name: "Daily digest" };
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
body() {
|
|
361
|
-
return (
|
|
362
|
-
<Email.Layout title="Daily digest">
|
|
363
|
-
<Email.Text>Here is what happened today.</Email.Text>
|
|
364
|
-
</Email.Layout>
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
366
|
+
const DailyDigestEmail = defineEmail<{ token: string }>({
|
|
367
|
+
subject: () => "Your daily digest",
|
|
368
|
+
unsubscribe: (props) => ({
|
|
369
|
+
url: `https://example.com/unsubscribe/${props.token}`,
|
|
370
|
+
mailto: "unsubscribe@example.com",
|
|
371
|
+
}),
|
|
372
|
+
list: () => ({ id: "daily-digest.example.com", name: "Daily digest" }),
|
|
373
|
+
body: () => () => (
|
|
374
|
+
<Email.Layout title="Daily digest">
|
|
375
|
+
<Email.Text>Here is what happened today.</Email.Text>
|
|
376
|
+
</Email.Layout>
|
|
377
|
+
),
|
|
378
|
+
});
|
|
368
379
|
|
|
369
380
|
let token = unwrap(await signUnsubscribeToken(secret, { subject: member.id, list: "digest" }));
|
|
370
|
-
await mailer.send(new DailyDigestEmail(member, token));
|
|
381
|
+
await mailer.send(new DailyDigestEmail(member.email, { token }));
|
|
371
382
|
```
|
|
372
383
|
|
|
373
384
|
```text
|
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
|
+
};
|
package/dist/context.js
ADDED
|
@@ -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();
|
package/dist/define.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineEmail()`: an email declared as its props, a subject and a body component, from
|
|
3
|
+
* which the package builds the class the mailer sends. Every email built this way is its own
|
|
4
|
+
* class, so a test still finds a sent message with `instanceof`.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Handle, RemixNode } from "remix/component";
|
|
10
|
+
import type { Address, CalendarPart, Email, MailingList, Unsubscribe } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* What an email says and looks like, written once per kind of email. Every member reads the
|
|
13
|
+
* props one instance was built with, so the subject and the body never disagree.
|
|
14
|
+
*
|
|
15
|
+
* @template Props - The data one email is built from, already translated for its reader.
|
|
16
|
+
*/
|
|
17
|
+
export interface EmailDefinition<Props extends object> {
|
|
18
|
+
/** Subject line, already in the reader's language. */
|
|
19
|
+
subject(props: Props): string;
|
|
20
|
+
/** Body component, rendered by the mailer into the HTML and plain-text parts. */
|
|
21
|
+
body: (handle: Handle<Props>) => () => RemixNode;
|
|
22
|
+
/** Per-email reply-to; the mailer's configuration supplies the default. */
|
|
23
|
+
replyTo?(props: Props): Address | Address[] | undefined;
|
|
24
|
+
/** Per-email headers, merged over the mailer's configured headers. */
|
|
25
|
+
headers?(props: Props): Record<string, string> | undefined;
|
|
26
|
+
/** One-click unsubscribe for optional mail, built per reader since its URL names them. */
|
|
27
|
+
unsubscribe?(props: Props): Unsubscribe | undefined;
|
|
28
|
+
/** List the email belongs to, written as `List-Id`. */
|
|
29
|
+
list?(props: Props): MailingList | undefined;
|
|
30
|
+
/** A calendar object, such as an invitation, sent alongside the rendered body. */
|
|
31
|
+
calendar?(props: Props): CalendarPart | undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* One email built by a {@link defineEmail} class, ready for `send()` or `later()`.
|
|
35
|
+
*
|
|
36
|
+
* @template Props - The data the email was built from.
|
|
37
|
+
*/
|
|
38
|
+
export interface DefinedEmail<Props extends object> extends Email {
|
|
39
|
+
/** Recipients, normalized to addresses from whatever form the constructor received. */
|
|
40
|
+
readonly to: Address | Address[];
|
|
41
|
+
/** The data the email was built from, so a test can assert on what a caller passed. */
|
|
42
|
+
readonly props: Props;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The class `defineEmail()` returns. A bare string recipient is the mailbox alone, the
|
|
46
|
+
* common case of mailing the address a record holds.
|
|
47
|
+
*
|
|
48
|
+
* @template Props - The data one email is built from.
|
|
49
|
+
*/
|
|
50
|
+
export interface EmailClass<Props extends object> {
|
|
51
|
+
new (to: string | Address | Address[], props: Props): DefinedEmail<Props>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Builds an email class from its subject and body, so an email is its props and its markup.
|
|
55
|
+
*
|
|
56
|
+
* @param definition - The subject, the body component and any per-email delivery options.
|
|
57
|
+
* @returns A class constructed with the recipient and the props, sendable by any mailer.
|
|
58
|
+
* @example export const WelcomeEmail = defineEmail<WelcomeEmail.Props>({ subject: (props) => props.heading, body: WelcomeBody });
|
|
59
|
+
* @example await mailer.send(new WelcomeEmail("ada@example.com", { heading: "Welcome" }));
|
|
60
|
+
*/
|
|
61
|
+
export declare function defineEmail<Props extends object>(definition: EmailDefinition<Props>): EmailClass<Props>;
|
package/dist/define.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineEmail()`: an email declared as its props, a subject and a body component, from
|
|
3
|
+
* which the package builds the class the mailer sends. Every email built this way is its own
|
|
4
|
+
* class, so a test still finds a sent message with `instanceof`.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { createElement } from "remix/component";
|
|
10
|
+
/**
|
|
11
|
+
* Builds an email class from its subject and body, so an email is its props and its markup.
|
|
12
|
+
*
|
|
13
|
+
* @param definition - The subject, the body component and any per-email delivery options.
|
|
14
|
+
* @returns A class constructed with the recipient and the props, sendable by any mailer.
|
|
15
|
+
* @example export const WelcomeEmail = defineEmail<WelcomeEmail.Props>({ subject: (props) => props.heading, body: WelcomeBody });
|
|
16
|
+
* @example await mailer.send(new WelcomeEmail("ada@example.com", { heading: "Welcome" }));
|
|
17
|
+
*/
|
|
18
|
+
export function defineEmail(definition) {
|
|
19
|
+
return class {
|
|
20
|
+
to;
|
|
21
|
+
props;
|
|
22
|
+
constructor(to, props) {
|
|
23
|
+
this.to = typeof to === "string" ? { email: to } : to;
|
|
24
|
+
this.props = props;
|
|
25
|
+
}
|
|
26
|
+
get subject() {
|
|
27
|
+
return definition.subject(this.props);
|
|
28
|
+
}
|
|
29
|
+
get replyTo() {
|
|
30
|
+
return definition.replyTo?.(this.props);
|
|
31
|
+
}
|
|
32
|
+
get headers() {
|
|
33
|
+
return definition.headers?.(this.props);
|
|
34
|
+
}
|
|
35
|
+
get unsubscribe() {
|
|
36
|
+
return definition.unsubscribe?.(this.props);
|
|
37
|
+
}
|
|
38
|
+
get list() {
|
|
39
|
+
return definition.list?.(this.props);
|
|
40
|
+
}
|
|
41
|
+
get calendar() {
|
|
42
|
+
return definition.calendar?.(this.props);
|
|
43
|
+
}
|
|
44
|
+
body() {
|
|
45
|
+
return createElement(definition.body, this.props);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
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 { DefinedEmail, EmailClass, EmailDefinition } from "./define.js";
|
|
13
14
|
export type { MailerOptions, SendOptions } from "./mailer.js";
|
|
14
15
|
export type { RenderedEmail } from "./render.js";
|
|
15
16
|
/**
|
|
@@ -20,6 +21,7 @@ export type { RenderedEmail } from "./render.js";
|
|
|
20
21
|
export type EmailTableRow = EmailComponents.Table.Row;
|
|
21
22
|
/** A web font for `Email.Layout` to declare, named here for the same reason as above. */
|
|
22
23
|
export type EmailFont = EmailComponents.Font;
|
|
24
|
+
export { defineEmail } from "./define.js";
|
|
23
25
|
export { MailError } from "./errors.js";
|
|
24
26
|
export { formatAddress, toAddressList } from "./lib/address.js";
|
|
25
27
|
export { htmlToText } from "./lib/html-to-text.js";
|
|
@@ -27,8 +29,8 @@ export { isEmail, Mailer } from "./mailer.js";
|
|
|
27
29
|
export { buildMimeMessage } from "./mime.js";
|
|
28
30
|
export { render } from "./render.js";
|
|
29
31
|
/**
|
|
30
|
-
* An email
|
|
31
|
-
*
|
|
32
|
+
* An email: who it goes to, what it says, and what it looks like, handed
|
|
33
|
+
* straight to `send()` or `later()`. Declared as an alias
|
|
32
34
|
* so it can share the `Email` identifier with the layout kit below.
|
|
33
35
|
*/
|
|
34
36
|
export type Email = EmailContract;
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* @copyright Sergio Xalambrí 2026
|
|
9
9
|
*/
|
|
10
10
|
import * as EmailComponents from "./components.js";
|
|
11
|
+
export { defineEmail } from "./define.js";
|
|
11
12
|
export { MailError } from "./errors.js";
|
|
12
13
|
export { formatAddress, toAddressList } from "./lib/address.js";
|
|
13
14
|
export { htmlToText } from "./lib/html-to-text.js";
|
package/dist/mailer.d.ts
CHANGED
|
@@ -58,7 +58,7 @@ export declare class Mailer {
|
|
|
58
58
|
* @param input - A plain message, or an email object that renders its own body.
|
|
59
59
|
* @param overrides - Fields that replace what the input provides for this send.
|
|
60
60
|
* @returns The provider's identifier on success, a `MailError` on failure.
|
|
61
|
-
* @example let result = await mailer.send(new TeamInviteEmail(invite));
|
|
61
|
+
* @example let result = await mailer.send(new TeamInviteEmail(invite.email, invite));
|
|
62
62
|
*/
|
|
63
63
|
send(input: Message | Email, overrides?: SendOptions): Promise<Result<SentMessage, MailError>>;
|
|
64
64
|
/**
|
package/dist/mailer.js
CHANGED
|
@@ -119,7 +119,7 @@ export class Mailer {
|
|
|
119
119
|
* @param input - A plain message, or an email object that renders its own body.
|
|
120
120
|
* @param overrides - Fields that replace what the input provides for this send.
|
|
121
121
|
* @returns The provider's identifier on success, a `MailError` on failure.
|
|
122
|
-
* @example let result = await mailer.send(new TeamInviteEmail(invite));
|
|
122
|
+
* @example let result = await mailer.send(new TeamInviteEmail(invite.email, invite));
|
|
123
123
|
*/
|
|
124
124
|
async send(input, overrides) {
|
|
125
125
|
let prepared = await this.#normalize(input, overrides);
|
package/dist/memory.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface RecordedDelivery {
|
|
|
34
34
|
*
|
|
35
35
|
* @example
|
|
36
36
|
* let transport = new MemoryTransport();
|
|
37
|
-
* await new Mailer({ transport, from: SENDER }).send(new TeamInviteEmail(invite));
|
|
37
|
+
* await new Mailer({ transport, from: SENDER }).send(new TeamInviteEmail(invite.email, invite));
|
|
38
38
|
* expect(transport.find((message) => message.email instanceof TeamInviteEmail)).toBeDefined();
|
|
39
39
|
*
|
|
40
40
|
* @example
|
package/dist/memory.js
CHANGED
|
@@ -17,7 +17,7 @@ import { buildMimeMessage } from "./mime.js";
|
|
|
17
17
|
*
|
|
18
18
|
* @example
|
|
19
19
|
* let transport = new MemoryTransport();
|
|
20
|
-
* await new Mailer({ transport, from: SENDER }).send(new TeamInviteEmail(invite));
|
|
20
|
+
* await new Mailer({ transport, from: SENDER }).send(new TeamInviteEmail(invite.email, invite));
|
|
21
21
|
* expect(transport.find((message) => message.email instanceof TeamInviteEmail)).toBeDefined();
|
|
22
22
|
*
|
|
23
23
|
* @example
|
package/dist/middleware.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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`.
|
package/dist/middleware.js
CHANGED
|
@@ -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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
35
|
+
context.set(CurrentMailer, mailer, { property: "email" });
|
|
35
36
|
try {
|
|
36
37
|
return await next();
|
|
37
38
|
}
|
package/dist/print.d.ts
ADDED
|
@@ -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/dist/types.d.ts
CHANGED
|
@@ -155,8 +155,8 @@ export interface Transport {
|
|
|
155
155
|
send(message: NormalizedMessage): Promise<Result<SentMessage, MailError>>;
|
|
156
156
|
}
|
|
157
157
|
/**
|
|
158
|
-
* An email
|
|
159
|
-
*
|
|
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
160
|
* translated from the caller's locale handling.
|
|
161
161
|
*/
|
|
162
162
|
export interface Email {
|
package/package.json
CHANGED
|
@@ -1,27 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/mail",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.17",
|
|
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
|
-
"./
|
|
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.
|
|
18
|
-
"@sdxc/logger": "2026.10.
|
|
19
|
-
"@sdxc/markdown": "2026.10.
|
|
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
|
-
"
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@sdxc/cli": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"gitHead": "4f4bce453e9afe69f53194368af9e9739d73a876",
|
|
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.
|
|
43
|
+
"homepage": "https://sdxc.dev/api/mail",
|
|
34
44
|
"author": {
|
|
35
45
|
"name": "Sergio Xalambrí",
|
|
36
46
|
"url": "https://sergiodxa.com"
|