@sdxc/mail 2026.10.16 → 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 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
- ### Author an email as a class
47
+ ### Define an email
48
48
 
49
49
  ```tsx
50
- import type { Handle } from "remix/component";
50
+ import { defineEmail, Email } from "@sdxc/mail";
51
51
 
52
- import { Email } from "@sdxc/mail";
52
+ interface TeamInvite {
53
+ team: string;
54
+ url: string;
55
+ }
53
56
 
54
- function InviteBody(handle: Handle<{ team: string; url: string }>) {
55
- return () => (
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
- `Email` is both the contract (type) and the layout kit (value), so one import covers
85
- `implements Email` and `<Email.Layout>`. The subject arrives as a string, already in the
86
- recipient's language.
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.
@@ -351,7 +346,7 @@ router.post("/password-reset", async (context) => {
351
346
  });
352
347
 
353
348
  router.post("/invites", async (context) => {
354
- 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
355
350
  return new Response(null, { status: 204 });
356
351
  });
357
352
  ```
@@ -364,48 +359,26 @@ Gmail and Yahoo require one-click unsubscribe on subscribed and marketing mail.
364
359
  its target and list, and the mailer writes the headers:
365
360
 
366
361
  ```tsx
367
- import type { MailingList, Unsubscribe } from "@sdxc/mail";
368
-
369
- import { Email } from "@sdxc/mail";
362
+ import { defineEmail, Email } from "@sdxc/mail";
370
363
  import { signUnsubscribeToken } from "@sdxc/mail/unsubscribe";
371
364
  import { unwrap } from "@sdxc/result";
372
365
 
373
- class DailyDigestEmail implements Email {
374
- constructor(
375
- private member: { id: string; email: string },
376
- private token: string,
377
- ) {}
378
-
379
- get to() {
380
- return { email: this.member.email };
381
- }
382
-
383
- get subject() {
384
- return "Your daily digest";
385
- }
386
-
387
- get unsubscribe(): Unsubscribe {
388
- return {
389
- url: `https://example.com/unsubscribe/${this.token}`,
390
- mailto: "unsubscribe@example.com",
391
- };
392
- }
393
-
394
- get list(): MailingList {
395
- return { id: "daily-digest.example.com", name: "Daily digest" };
396
- }
397
-
398
- body() {
399
- return (
400
- <Email.Layout title="Daily digest">
401
- <Email.Text>Here is what happened today.</Email.Text>
402
- </Email.Layout>
403
- );
404
- }
405
- }
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
+ });
406
379
 
407
380
  let token = unwrap(await signUnsubscribeToken(secret, { subject: member.id, list: "digest" }));
408
- await mailer.send(new DailyDigestEmail(member, token));
381
+ await mailer.send(new DailyDigestEmail(member.email, { token }));
409
382
  ```
410
383
 
411
384
  ```text
@@ -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 authored as a class: who it goes to, what it says, and what it
31
- * looks like, handed straight to `send()` or `later()`. Declared as an alias
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/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 authored as a class: who it goes to, what it says, and what it looks
159
- * like. Sender identity lives on the mailer, and a subject arrives already
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/mail",
3
- "version": "2026.10.16",
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",
@@ -31,7 +31,7 @@
31
31
  "optional": true
32
32
  }
33
33
  },
34
- "gitHead": "1e547d8431568f5ed9bdb079ca3046c936240fde",
34
+ "gitHead": "4f4bce453e9afe69f53194368af9e9739d73a876",
35
35
  "publishConfig": {
36
36
  "access": "public"
37
37
  },