@wtfalch/mailer 0.1.0 → 0.3.0

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
@@ -31,7 +31,11 @@ await send({
31
31
  fromAddress: 'no-reply@acme.wtfalch.dev',
32
32
  fromName: 'Acme', // optional
33
33
  replyTo: 'support@acme.wtfalch.dev', // optional
34
+ locale: 'nb', // optional, defaults to 'en' -- picks which language's copy renders
34
35
  },
36
+ attachments: [ // optional
37
+ { filename: 'invoice.pdf', contentType: 'application/pdf', content: 'JVBERi0xLjQK...' }, // content is base64
38
+ ],
35
39
  transport: createDevTransport(), // writes to .mailer/dev instead of sending
36
40
  });
37
41
  ```
@@ -41,20 +45,50 @@ await send({
41
45
  missing transport fails loudly instead of silently no-op'ing or making a
42
46
  live call with no key.
43
47
 
48
+ A held key: however you resolve `apiKey` for `createResendTransport`,
49
+ mailer never fetches or stores it. In the estate, the caller resolves the
50
+ org's Resend key as a held key from `@wtfalch/keys` and passes it in on
51
+ every call.
52
+
53
+ ### Locale
54
+
55
+ Every template ships English (`en`, the fallback) and Norwegian bokmål
56
+ (`nb`) copy. Set `from.locale` to pick which one renders; leave it unset
57
+ for English. An unrecognised locale falls back to English rather than
58
+ throwing -- the same fallback shape the auth broker's own `compose()`
59
+ already uses (`WORDS[sender.locale] ?? WORDS.en`).
60
+
44
61
  ### Transports
45
62
 
63
+ A held key: however you resolve `apiKey` for either transport below, mailer
64
+ never fetches or stores it. In the estate, the caller resolves the org's
65
+ Resend or Lettermint key as a held key from `@wtfalch/keys` and passes it
66
+ in on every call.
67
+
46
68
  - `createDevTransport({ dir })` -- writes each message to
47
69
  `<dir>/<timestamp>-<id>.json` (default `dir`: `.mailer/dev`). Never
48
70
  opens a network connection.
49
71
  - `createResendTransport({ apiKey })` -- sends over Resend. Needs either
50
72
  `apiKey` (a plain string -- resolve it yourself, e.g. from
51
73
  `@wtfalch/keys`) or `client`, a minimal structural `ResendLike` for
52
- injecting a fake in tests.
74
+ injecting a fake in tests. `resend` is an optional peer dependency
75
+ (`^4.1.0`) -- install it yourself if you use this transport with `apiKey`;
76
+ not needed if you always inject `client`, or if you only use
77
+ `createLettermintTransport` below. Attachments passed to `send()` are
78
+ forwarded as Resend's own `{ filename, content, content_type }` shape.
79
+ - `createLettermintTransport({ apiKey })` -- sends over Lettermint's
80
+ Sending API (EU-hosted). Needs either `apiKey` (the org's Lettermint
81
+ Project API token, `lm_...` -- resolve it yourself, e.g. from
82
+ `@wtfalch/keys`) or `client`, a minimal structural `LettermintLike` for
83
+ injecting a fake in tests. Speaks plain `fetch`, no SDK dependency; pass
84
+ `fetch` to inject your own. If Lettermint refuses the send because the
85
+ `from` domain is not verified, or not allowed for the sending project, the
86
+ call rejects with `LettermintDomainRefusedError` rather than a generic
87
+ `Error`. Attachments passed to `send()` are forwarded as Lettermint's own
88
+ `{ filename, content, content_type }` shape.
53
89
 
54
90
  ### Templates
55
91
 
56
- `invitation` is the only template shipped so far:
57
-
58
92
  ```ts
59
93
  interface InvitationData {
60
94
  organisationName: string;
@@ -65,6 +99,41 @@ interface InvitationData {
65
99
  }
66
100
  ```
67
101
 
102
+ Seven account templates for the flows an auth system needs --
103
+ `verify`, `reset`, `link` (a sign-in link), `invite` (an account invite),
104
+ `email-change`, `email-change-notice` and `email-changed`. The first five
105
+ share one shape; `email-change-notice` and `email-changed` go to the
106
+ address a change does *not* touch (a request notice, and a completed-change
107
+ notice), so they carry no link or code, only the new address to notice:
108
+
109
+ ```ts
110
+ interface AccountMailData {
111
+ productName: string;
112
+ url: string;
113
+ code: string;
114
+ brand?: { name: string; logoUrl?: string; primaryColor?: string };
115
+ }
116
+
117
+ interface EmailChangeNoticeData {
118
+ productName: string;
119
+ newEmail: string;
120
+ brand?: { name: string; logoUrl?: string; primaryColor?: string };
121
+ }
122
+ ```
123
+
124
+ ```ts
125
+ await send({
126
+ template: 'verify',
127
+ to: 'person@example.com',
128
+ data: { productName: 'Acme', url: 'https://app.example.com/verify/abc', code: '482913' },
129
+ from: { fromAddress: 'no-reply@acme.wtfalch.dev', locale: 'nb' },
130
+ });
131
+ ```
132
+
133
+ Modelled on the auth broker's own account mail (`auth/broker/src/mail.ts`)
134
+ so a caller there can move to `@wtfalch/mailer` directly -- all seven of
135
+ the broker's `Kind`s are covered.
136
+
68
137
  ## Tests
69
138
 
70
139
  From `packages/mailer`:
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Throws `MailerConfigError` if `value` contains a control character, so a
3
+ * caller-supplied string never reaches a mail header carrying one. Returns
4
+ * `value` unchanged otherwise, for use inline at each call site.
5
+ */
6
+ export declare function assertHeaderSafe(value: string, field: string): string;
@@ -0,0 +1,31 @@
1
+ import { MailerConfigError } from './types.js';
2
+ /** The C0 range (0x00-0x1F, which includes CR and LF) plus DEL (0x7F). */
3
+ function isControlCharCode(code) {
4
+ return code <= 0x1f || code === 0x7f;
5
+ }
6
+ /**
7
+ * True if `value` contains a CR, LF or any other control character. Checked
8
+ * by character code rather than a regex, since a header-bound field is
9
+ * built in part from caller-supplied strings (an inviter's name, an org's
10
+ * name) and this package must not depend on Resend's own defence against
11
+ * header injection to reject them (see issue #3).
12
+ */
13
+ function hasControlCharacter(value) {
14
+ for (let i = 0; i < value.length; i++) {
15
+ const code = value.charCodeAt(i);
16
+ if (isControlCharCode(code))
17
+ return true;
18
+ }
19
+ return false;
20
+ }
21
+ /**
22
+ * Throws `MailerConfigError` if `value` contains a control character, so a
23
+ * caller-supplied string never reaches a mail header carrying one. Returns
24
+ * `value` unchanged otherwise, for use inline at each call site.
25
+ */
26
+ export function assertHeaderSafe(value, field) {
27
+ if (hasControlCharacter(value)) {
28
+ throw new MailerConfigError(`mailer: "${field}" contains a control character (e.g. CR or LF) and cannot be used in a mail header.`);
29
+ }
30
+ return value;
31
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,12 @@
1
- export type { Brand, MailMessage, SenderIdentity, SendResult } from './types.js';
2
- export { MailerConfigError } from './types.js';
3
- export type { DevTransportOptions, OutgoingMail, ResendLike, ResendTransportOptions, Transport, } from './transport.js';
4
- export { createDevTransport, createResendTransport, defaultTransport } from './transport.js';
1
+ export type { Attachment, Brand, Locale, MailMessage, SenderIdentity, SendResult, } from './types.js';
2
+ export { DEFAULT_LOCALE, MailerConfigError } from './types.js';
3
+ export type { DevTransportOptions, LettermintAttachment, LettermintFetchLike, LettermintLike, LettermintSendPayload, LettermintTransportOptions, OutgoingMail, ResendLike, ResendTransportOptions, Transport, } from './transport.js';
4
+ export { createDevTransport, createLettermintTransport, createResendTransport, defaultTransport, LettermintDomainRefusedError, } from './transport.js';
5
5
  export type { SendOptions } from './send.js';
6
6
  export { send } from './send.js';
7
7
  export type { TemplateData, TemplateName } from './templates/index.js';
8
8
  export { templates } from './templates/index.js';
9
9
  export type { InvitationData } from './templates/invitation.js';
10
10
  export { renderInvitation } from './templates/invitation.js';
11
+ export type { AccountMailData, EmailChangeNoticeData } from './templates/account.js';
12
+ export { renderAccountInvite, renderEmailChange, renderEmailChangeNotice, renderEmailChanged, renderReset, renderSignInLink, renderVerify, } from './templates/account.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
- export { MailerConfigError } from './types.js';
2
- export { createDevTransport, createResendTransport, defaultTransport } from './transport.js';
1
+ export { DEFAULT_LOCALE, MailerConfigError } from './types.js';
2
+ export { createDevTransport, createLettermintTransport, createResendTransport, defaultTransport, LettermintDomainRefusedError, } from './transport.js';
3
3
  export { send } from './send.js';
4
4
  export { templates } from './templates/index.js';
5
5
  export { renderInvitation } from './templates/invitation.js';
6
+ export { renderAccountInvite, renderEmailChange, renderEmailChangeNotice, renderEmailChanged, renderReset, renderSignInLink, renderVerify, } from './templates/account.js';
package/dist/layout.d.ts CHANGED
@@ -1,9 +1,4 @@
1
1
  import type { Brand, MailMessage } from './types.js';
2
- /**
3
- * The one rule. Ampersand first, or it double-escapes everything after it.
4
- * Same rule and reason as app-template's `src/lib/marketing/render.ts`.
5
- */
6
- export declare function escapeHtml(value: string): string;
7
2
  export interface LayoutOptions {
8
3
  readonly subject: string;
9
4
  /** Already-escaped HTML for the message body. */
@@ -26,5 +21,9 @@ export interface LayoutOptions {
26
21
  * Every template is responsible for escaping its own interpolated values
27
22
  * with `escapeHtml` before calling this -- the layout only wraps what it is
28
23
  * given, except for `brand.name`, which it escapes itself.
24
+ *
25
+ * `subject` is checked for CR/LF and other control characters before it is
26
+ * returned, since it is not HTML-escaped and reaches Resend as a header
27
+ * value -- see `header-safe.ts`.
29
28
  */
30
29
  export declare function layout(options: LayoutOptions): MailMessage;
package/dist/layout.js CHANGED
@@ -1,14 +1,5 @@
1
- /**
2
- * The one rule. Ampersand first, or it double-escapes everything after it.
3
- * Same rule and reason as app-template's `src/lib/marketing/render.ts`.
4
- */
5
- export function escapeHtml(value) {
6
- return value
7
- .replace(/&/g, '&amp;')
8
- .replace(/</g, '&lt;')
9
- .replace(/>/g, '&gt;')
10
- .replace(/"/g, '&quot;');
11
- }
1
+ import { escapeHtml } from '@wtfalch/utils/html';
2
+ import { assertHeaderSafe } from './header-safe.js';
12
3
  const BODY_STYLE = 'margin:0;padding:24px;background:#f4f6f8;font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;color:#15181d;line-height:1.6';
13
4
  const CARD_STYLE = 'max-width:600px;margin:0 auto;background:#ffffff;padding:28px;border-radius:4px';
14
5
  const BRAND_STYLE = 'margin:0 0 20px;font-size:13px;color:#545b66;text-transform:uppercase;letter-spacing:0.04em';
@@ -27,6 +18,10 @@ const BRAND_STYLE = 'margin:0 0 20px;font-size:13px;color:#545b66;text-transform
27
18
  * Every template is responsible for escaping its own interpolated values
28
19
  * with `escapeHtml` before calling this -- the layout only wraps what it is
29
20
  * given, except for `brand.name`, which it escapes itself.
21
+ *
22
+ * `subject` is checked for CR/LF and other control characters before it is
23
+ * returned, since it is not HTML-escaped and reaches Resend as a header
24
+ * value -- see `header-safe.ts`.
30
25
  */
31
26
  export function layout(options) {
32
27
  const header = options.brand?.name
@@ -42,5 +37,5 @@ export function layout(options) {
42
37
  options.bodyHtml,
43
38
  '</div></body></html>',
44
39
  ].join('');
45
- return { subject: options.subject, html, text: options.bodyText };
40
+ return { subject: assertHeaderSafe(options.subject, 'subject'), html, text: options.bodyText };
46
41
  }
package/dist/send.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type TemplateData, type TemplateName } from './templates/index.js';
2
2
  import { type Transport } from './transport.js';
3
- import type { SendResult, SenderIdentity } from './types.js';
3
+ import { type Attachment, type SendResult, type SenderIdentity } from './types.js';
4
4
  export interface SendOptions<T extends TemplateName> {
5
5
  readonly template: T;
6
6
  readonly to: string;
@@ -8,6 +8,8 @@ export interface SendOptions<T extends TemplateName> {
8
8
  readonly from: SenderIdentity;
9
9
  /** Defaults to the dev transport outside production; see `defaultTransport`. */
10
10
  readonly transport?: Transport;
11
+ /** Files to attach, e.g. an invoice or credit note document. Both shipped transports accept these. */
12
+ readonly attachments?: readonly Attachment[];
11
13
  }
12
14
  /**
13
15
  * One template, rendered and handed to a transport.
package/dist/send.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { assertHeaderSafe } from './header-safe.js';
1
2
  import { templates } from './templates/index.js';
2
3
  import { defaultTransport } from './transport.js';
4
+ import { DEFAULT_LOCALE, } from './types.js';
3
5
  function formatFrom(identity) {
4
6
  return identity.fromName
5
7
  ? `${identity.fromName} <${identity.fromAddress}>`
@@ -15,12 +17,13 @@ function formatFrom(identity) {
15
17
  */
16
18
  export async function send(options) {
17
19
  const render = templates[options.template];
18
- const message = render(options.data);
20
+ const message = render(options.data, options.from.locale ?? DEFAULT_LOCALE);
19
21
  const transport = options.transport ?? defaultTransport();
20
22
  return transport.send({
21
23
  ...message,
22
- to: options.to,
23
- from: formatFrom(options.from),
24
- replyTo: options.from.replyTo,
24
+ to: assertHeaderSafe(options.to, 'to'),
25
+ from: assertHeaderSafe(formatFrom(options.from), 'from'),
26
+ replyTo: options.from.replyTo ? assertHeaderSafe(options.from.replyTo, 'replyTo') : undefined,
27
+ attachments: options.attachments,
25
28
  });
26
29
  }
@@ -0,0 +1,34 @@
1
+ import { type Brand, type Locale, type MailMessage } from '../types.js';
2
+ /**
3
+ * Shared shape for every account template that carries a link and a code:
4
+ * `verify`, `reset`, `link` (sign-in link), `invite` (account invite) and
5
+ * `email-change`. Modelled on the auth broker's own `Sender`/`Message`
6
+ * variables (`auth/broker/src/mail.ts`, read for this template's copy and
7
+ * variables so the broker can move over -- issue #10), rendered in mailer's
8
+ * own button-plus-fallback-link convention (`templates/invitation.ts`)
9
+ * rather than the broker's plain-text-only `Message`.
10
+ */
11
+ export interface AccountMailData {
12
+ readonly productName: string;
13
+ readonly url: string;
14
+ readonly code: string;
15
+ readonly brand?: Brand;
16
+ }
17
+ /**
18
+ * `email-change-notice` and `email-changed` both go to the address a
19
+ * change does NOT touch: no link to click and no code to enter, only
20
+ * something to notice -- the same exception the broker's own `WORDS` table
21
+ * carves out for these two kinds (`mail.ts`'s doc comment on `WORDS`).
22
+ */
23
+ export interface EmailChangeNoticeData {
24
+ readonly productName: string;
25
+ readonly newEmail: string;
26
+ readonly brand?: Brand;
27
+ }
28
+ export declare function renderVerify(data: AccountMailData, locale?: Locale): MailMessage;
29
+ export declare function renderReset(data: AccountMailData, locale?: Locale): MailMessage;
30
+ export declare function renderSignInLink(data: AccountMailData, locale?: Locale): MailMessage;
31
+ export declare function renderAccountInvite(data: AccountMailData, locale?: Locale): MailMessage;
32
+ export declare function renderEmailChange(data: AccountMailData, locale?: Locale): MailMessage;
33
+ export declare function renderEmailChangeNotice(data: EmailChangeNoticeData, locale?: Locale): MailMessage;
34
+ export declare function renderEmailChanged(data: EmailChangeNoticeData, locale?: Locale): MailMessage;
@@ -0,0 +1,171 @@
1
+ import { escapeHtml } from '@wtfalch/utils/html';
2
+ import { layout } from '../layout.js';
3
+ import { DEFAULT_LOCALE } from '../types.js';
4
+ const ACCOUNT_WORDS = {
5
+ en: {
6
+ verify: {
7
+ subject: (p) => `Confirm your email for ${p}`,
8
+ intro: (p) => `Confirm your email address to finish setting up your ${p} account.`,
9
+ footer: () => 'If you did not create an account, ignore this message.',
10
+ cta: 'Confirm email',
11
+ codeLabel: 'Or enter this code: ',
12
+ fallbackLinkLabel: 'Or paste this link into your browser: ',
13
+ },
14
+ reset: {
15
+ subject: (p) => `Reset your ${p} password`,
16
+ intro: (p) => `Choose a new password for ${p}.`,
17
+ footer: () => 'The link works once and expires. If you did not ask for it, your password is unchanged and you can ignore this message.',
18
+ cta: 'Reset password',
19
+ codeLabel: 'Or enter this code: ',
20
+ fallbackLinkLabel: 'Or paste this link into your browser: ',
21
+ },
22
+ link: {
23
+ subject: (p) => `Sign in to ${p}`,
24
+ intro: (p) => `Sign in to ${p}.`,
25
+ footer: () => 'The link works once and expires shortly. If you did not ask to sign in, ignore this message.',
26
+ cta: 'Sign in',
27
+ codeLabel: 'Or enter this code: ',
28
+ fallbackLinkLabel: 'Or paste this link into your browser: ',
29
+ },
30
+ invite: {
31
+ subject: (p) => `You have been added to ${p}`,
32
+ intro: (p) => `You have been given access to ${p}. Choose a password to finish.`,
33
+ footer: () => 'If you were not expecting this, ignore the message and no account is usable.',
34
+ cta: 'Set your password',
35
+ codeLabel: 'Or enter this code: ',
36
+ fallbackLinkLabel: 'Or paste this link into your browser: ',
37
+ },
38
+ 'email-change': {
39
+ subject: (p) => `Confirm your new email for ${p}`,
40
+ intro: (p) => `Confirm this address as your new sign-in email for ${p}. Your old address keeps working until you do.`,
41
+ footer: () => 'If you did not ask to change your email, ignore this message -- nothing changes until you confirm.',
42
+ cta: 'Confirm new email',
43
+ codeLabel: 'Or enter this code: ',
44
+ fallbackLinkLabel: 'Or paste this link into your browser: ',
45
+ },
46
+ },
47
+ nb: {
48
+ verify: {
49
+ subject: (p) => `Bekreft e-postadressen din for ${p}`,
50
+ intro: (p) => `Bekreft e-postadressen din for å fullføre ${p}-kontoen din.`,
51
+ footer: () => 'Hvis du ikke har opprettet en konto, kan du se bort fra denne meldingen.',
52
+ cta: 'Bekreft e-post',
53
+ codeLabel: 'Eller skriv inn denne koden: ',
54
+ fallbackLinkLabel: 'Eller lim inn denne lenken i nettleseren din: ',
55
+ },
56
+ reset: {
57
+ subject: (p) => `Tilbakestill passordet ditt for ${p}`,
58
+ intro: (p) => `Velg et nytt passord for ${p}.`,
59
+ footer: () => 'Lenken virker én gang og utløper. Hvis du ikke ba om den, er passordet ditt uendret.',
60
+ cta: 'Tilbakestill passord',
61
+ codeLabel: 'Eller skriv inn denne koden: ',
62
+ fallbackLinkLabel: 'Eller lim inn denne lenken i nettleseren din: ',
63
+ },
64
+ link: {
65
+ subject: (p) => `Logg inn på ${p}`,
66
+ intro: (p) => `Logg inn på ${p}.`,
67
+ footer: () => 'Lenken virker én gang og utløper snart. Hvis du ikke ba om å logge inn, kan du se bort fra denne meldingen.',
68
+ cta: 'Logg inn',
69
+ codeLabel: 'Eller skriv inn denne koden: ',
70
+ fallbackLinkLabel: 'Eller lim inn denne lenken i nettleseren din: ',
71
+ },
72
+ invite: {
73
+ subject: (p) => `Du har fått tilgang til ${p}`,
74
+ intro: (p) => `Du har fått tilgang til ${p}. Velg et passord for å fullføre.`,
75
+ footer: () => 'Hvis du ikke ventet dette, kan du se bort fra meldingen; kontoen er ikke i bruk.',
76
+ cta: 'Velg passord',
77
+ codeLabel: 'Eller skriv inn denne koden: ',
78
+ fallbackLinkLabel: 'Eller lim inn denne lenken i nettleseren din: ',
79
+ },
80
+ 'email-change': {
81
+ subject: (p) => `Bekreft den nye e-postadressen din for ${p}`,
82
+ intro: (p) => `Bekreft denne adressen som den nye påloggingsadressen din for ${p}. Den gamle adressen fungerer fortsatt til du gjør det.`,
83
+ footer: () => 'Hvis du ikke ba om å endre e-postadressen din, kan du se bort fra denne meldingen -- ingenting endres før du bekrefter.',
84
+ cta: 'Bekreft ny e-post',
85
+ codeLabel: 'Eller skriv inn denne koden: ',
86
+ fallbackLinkLabel: 'Eller lim inn denne lenken i nettleseren din: ',
87
+ },
88
+ },
89
+ };
90
+ function renderAccountMail(kind, data, locale) {
91
+ const words = (ACCOUNT_WORDS[locale] ?? ACCOUNT_WORDS.en)[kind];
92
+ const subject = words.subject(data.productName);
93
+ const bodyHtml = [
94
+ `<p style="margin:0 0 14px">${escapeHtml(words.intro(data.productName))}</p>`,
95
+ `<p style="margin:0 0 20px"><a href="${escapeHtml(data.url)}" `,
96
+ 'style="display:inline-block;background:#0e6c70;color:#ffffff;padding:10px 20px;',
97
+ `border-radius:4px;text-decoration:none">${escapeHtml(words.cta)}</a></p>`,
98
+ `<p style="margin:0 0 14px;font-size:13px;color:#545b66">${escapeHtml(words.fallbackLinkLabel)}`,
99
+ `${escapeHtml(data.url)}</p>`,
100
+ `<p style="margin:0 0 20px;font-size:13px;color:#545b66">${escapeHtml(words.codeLabel)}${escapeHtml(data.code)}</p>`,
101
+ `<p style="margin:0;font-size:13px;color:#545b66">${escapeHtml(words.footer(data.productName))}</p>`,
102
+ ].join('');
103
+ const text = [
104
+ words.intro(data.productName),
105
+ '',
106
+ data.url,
107
+ '',
108
+ `${words.codeLabel}${data.code}`,
109
+ '',
110
+ words.footer(data.productName),
111
+ ].join('\n');
112
+ return layout({ subject, bodyHtml, bodyText: text, brand: data.brand });
113
+ }
114
+ export function renderVerify(data, locale = DEFAULT_LOCALE) {
115
+ return renderAccountMail('verify', data, locale);
116
+ }
117
+ export function renderReset(data, locale = DEFAULT_LOCALE) {
118
+ return renderAccountMail('reset', data, locale);
119
+ }
120
+ export function renderSignInLink(data, locale = DEFAULT_LOCALE) {
121
+ return renderAccountMail('link', data, locale);
122
+ }
123
+ export function renderAccountInvite(data, locale = DEFAULT_LOCALE) {
124
+ return renderAccountMail('invite', data, locale);
125
+ }
126
+ export function renderEmailChange(data, locale = DEFAULT_LOCALE) {
127
+ return renderAccountMail('email-change', data, locale);
128
+ }
129
+ const NOTICE_WORDS = {
130
+ en: {
131
+ 'email-change-notice': {
132
+ subject: (p) => `Someone requested a new email for your ${p} account`,
133
+ body: (p, newEmail) => `A request was made to change the sign-in email on your ${p} account to ${newEmail}. Nothing has changed yet -- this address still works, and the change only takes effect if ${newEmail} is confirmed.`,
134
+ footer: 'If this was not you, please contact us right away -- somebody else may have access to your account.',
135
+ },
136
+ 'email-changed': {
137
+ subject: (p) => `Your ${p} sign-in email was changed`,
138
+ body: (p, newEmail) => `Your ${p} account's sign-in email was changed to ${newEmail}. This address no longer works for signing in.`,
139
+ footer: 'If you did not make this change, please contact us right away -- somebody else has access to your account.',
140
+ },
141
+ },
142
+ nb: {
143
+ 'email-change-notice': {
144
+ subject: (p) => `Noen ba om en ny e-postadresse for ${p}-kontoen din`,
145
+ body: (p, newEmail) => `Det ble bedt om å endre påloggings-e-posten for ${p}-kontoen din til ${newEmail}. Ingenting er endret ennå -- denne adressen fungerer fortsatt, og endringen trer bare i kraft hvis ${newEmail} blir bekreftet.`,
146
+ footer: 'Hvis dette ikke var deg, kontakt oss med en gang -- noen andre kan ha tilgang til kontoen din.',
147
+ },
148
+ 'email-changed': {
149
+ subject: (p) => `Påloggings-e-posten din for ${p} ble endret`,
150
+ body: (p, newEmail) => `Påloggings-e-posten for ${p}-kontoen din ble endret til ${newEmail}. Denne adressen fungerer ikke lenger for pålogging.`,
151
+ footer: 'Hvis du ikke gjorde denne endringen, kontakt oss med en gang -- noen andre har tilgang til kontoen din.',
152
+ },
153
+ },
154
+ };
155
+ function renderNoticeMail(kind, data, locale) {
156
+ const words = (NOTICE_WORDS[locale] ?? NOTICE_WORDS.en)[kind];
157
+ const subject = words.subject(data.productName);
158
+ const bodyText = words.body(data.productName, data.newEmail);
159
+ const bodyHtml = [
160
+ `<p style="margin:0 0 14px">${escapeHtml(bodyText)}</p>`,
161
+ `<p style="margin:0;font-size:13px;color:#545b66">${escapeHtml(words.footer)}</p>`,
162
+ ].join('');
163
+ const text = [bodyText, '', words.footer].join('\n');
164
+ return layout({ subject, bodyHtml, bodyText: text, brand: data.brand });
165
+ }
166
+ export function renderEmailChangeNotice(data, locale = DEFAULT_LOCALE) {
167
+ return renderNoticeMail('email-change-notice', data, locale);
168
+ }
169
+ export function renderEmailChanged(data, locale = DEFAULT_LOCALE) {
170
+ return renderNoticeMail('email-changed', data, locale);
171
+ }
@@ -1,12 +1,19 @@
1
+ import { type AccountMailData, type EmailChangeNoticeData, renderAccountInvite, renderEmailChange, renderEmailChangeNotice, renderEmailChanged, renderReset, renderSignInLink, renderVerify } from './account.js';
1
2
  import { type InvitationData, renderInvitation } from './invitation.js';
2
3
  /**
3
4
  * Every template this package ships, by name. Adding one is additive to
4
- * this map and to the surface lock; v1 ships one, `invitation` -- the
5
- * README names sign-in links, alerts and forum notifications as future
6
- * templates, not built here (see .claude/campaign/2026-09-22-v1.md, Findings).
5
+ * this map and to the surface lock. v1 shipped one, `invitation`; v2 adds
6
+ * all seven of the auth broker's own `Kind` union names (issue #10).
7
7
  */
8
8
  export declare const templates: {
9
9
  readonly invitation: typeof renderInvitation;
10
+ readonly verify: typeof renderVerify;
11
+ readonly reset: typeof renderReset;
12
+ readonly link: typeof renderSignInLink;
13
+ readonly invite: typeof renderAccountInvite;
14
+ readonly 'email-change': typeof renderEmailChange;
15
+ readonly 'email-change-notice': typeof renderEmailChangeNotice;
16
+ readonly 'email-changed': typeof renderEmailChanged;
10
17
  };
11
18
  export type TemplateName = keyof typeof templates;
12
- export type TemplateData<T extends TemplateName> = T extends 'invitation' ? InvitationData : never;
19
+ export type TemplateData<T extends TemplateName> = T extends 'invitation' ? InvitationData : T extends 'email-change-notice' | 'email-changed' ? EmailChangeNoticeData : T extends 'verify' | 'reset' | 'link' | 'invite' | 'email-change' ? AccountMailData : never;
@@ -1,10 +1,17 @@
1
+ import { renderAccountInvite, renderEmailChange, renderEmailChangeNotice, renderEmailChanged, renderReset, renderSignInLink, renderVerify, } from './account.js';
1
2
  import { renderInvitation } from './invitation.js';
2
3
  /**
3
4
  * Every template this package ships, by name. Adding one is additive to
4
- * this map and to the surface lock; v1 ships one, `invitation` -- the
5
- * README names sign-in links, alerts and forum notifications as future
6
- * templates, not built here (see .claude/campaign/2026-09-22-v1.md, Findings).
5
+ * this map and to the surface lock. v1 shipped one, `invitation`; v2 adds
6
+ * all seven of the auth broker's own `Kind` union names (issue #10).
7
7
  */
8
8
  export const templates = {
9
9
  invitation: renderInvitation,
10
+ verify: renderVerify,
11
+ reset: renderReset,
12
+ link: renderSignInLink,
13
+ invite: renderAccountInvite,
14
+ 'email-change': renderEmailChange,
15
+ 'email-change-notice': renderEmailChangeNotice,
16
+ 'email-changed': renderEmailChanged,
10
17
  };
@@ -1,4 +1,4 @@
1
- import type { Brand, MailMessage } from '../types.js';
1
+ import { type Brand, type Locale, type MailMessage } from '../types.js';
2
2
  /**
3
3
  * The estate's real first consumer: app-template's invite action creates
4
4
  * the invitation row and returns "Invitation sent" without ever sending
@@ -13,4 +13,4 @@ export interface InvitationData {
13
13
  readonly roleName?: string;
14
14
  readonly brand?: Brand;
15
15
  }
16
- export declare function renderInvitation(data: InvitationData): MailMessage;
16
+ export declare function renderInvitation(data: InvitationData, locale?: Locale): MailMessage;
@@ -1,20 +1,42 @@
1
- import { escapeHtml, layout } from '../layout.js';
2
- export function renderInvitation(data) {
3
- const subject = `${data.inviterName} invited you to ${data.organisationName}`;
4
- const roleLine = data.roleName ? ` as ${escapeHtml(data.roleName)}` : '';
1
+ import { escapeHtml } from '@wtfalch/utils/html';
2
+ import { layout } from '../layout.js';
3
+ import { DEFAULT_LOCALE } from '../types.js';
4
+ const WORDS = {
5
+ en: {
6
+ subject: (inviter, org) => `${inviter} invited you to ${org}`,
7
+ intro: (inviter, org) => `${escapeHtml(inviter)} invited you to join <strong>${escapeHtml(org)}</strong>`,
8
+ roleSuffix: (role) => ` as ${escapeHtml(role)}`,
9
+ cta: 'Accept invitation',
10
+ fallbackLinkLabel: 'Or paste this link into your browser: ',
11
+ textIntro: (inviter, org, role) => `${inviter} invited you to join ${org}${role ? ` as ${role}` : ''}.`,
12
+ textAccept: 'Accept',
13
+ },
14
+ nb: {
15
+ subject: (inviter, org) => `${inviter} har invitert deg til ${org}`,
16
+ intro: (inviter, org) => `${escapeHtml(inviter)} har invitert deg til å bli med i <strong>${escapeHtml(org)}</strong>`,
17
+ roleSuffix: (role) => ` som ${escapeHtml(role)}`,
18
+ cta: 'Godta invitasjon',
19
+ fallbackLinkLabel: 'Eller lim inn denne lenken i nettleseren din: ',
20
+ textIntro: (inviter, org, role) => `${inviter} har invitert deg til å bli med i ${org}${role ? ` som ${role}` : ''}.`,
21
+ textAccept: 'Godta',
22
+ },
23
+ };
24
+ export function renderInvitation(data, locale = DEFAULT_LOCALE) {
25
+ const words = WORDS[locale] ?? WORDS.en;
26
+ const subject = words.subject(data.inviterName, data.organisationName);
27
+ const roleLine = data.roleName ? words.roleSuffix(data.roleName) : '';
5
28
  const bodyHtml = [
6
- `<p style="margin:0 0 14px">${escapeHtml(data.inviterName)} invited you to join `,
7
- `<strong>${escapeHtml(data.organisationName)}</strong>${roleLine}.</p>`,
29
+ `<p style="margin:0 0 14px">${words.intro(data.inviterName, data.organisationName)}${roleLine}.</p>`,
8
30
  `<p style="margin:0 0 20px"><a href="${escapeHtml(data.acceptUrl)}" `,
9
31
  'style="display:inline-block;background:#0e6c70;color:#ffffff;padding:10px 20px;',
10
- 'border-radius:4px;text-decoration:none">Accept invitation</a></p>',
11
- '<p style="margin:0;font-size:13px;color:#545b66">Or paste this link into your browser: ',
32
+ `border-radius:4px;text-decoration:none">${escapeHtml(words.cta)}</a></p>`,
33
+ `<p style="margin:0;font-size:13px;color:#545b66">${escapeHtml(words.fallbackLinkLabel)}`,
12
34
  `${escapeHtml(data.acceptUrl)}</p>`,
13
35
  ].join('');
14
36
  const text = [
15
- `${data.inviterName} invited you to join ${data.organisationName}${data.roleName ? ` as ${data.roleName}` : ''}.`,
37
+ words.textIntro(data.inviterName, data.organisationName, data.roleName),
16
38
  '',
17
- `Accept: ${data.acceptUrl}`,
39
+ `${words.textAccept}: ${data.acceptUrl}`,
18
40
  ].join('\n');
19
41
  return layout({ subject, bodyHtml, bodyText: text, brand: data.brand });
20
42
  }
@@ -1,10 +1,11 @@
1
- import { type MailMessage, type SendResult } from './types.js';
1
+ import { type Attachment, type MailMessage, type SendResult } from './types.js';
2
2
  /** A rendered message, addressed and ready for a transport. */
3
3
  export interface OutgoingMail extends MailMessage {
4
4
  readonly to: string;
5
5
  /** Already formatted, e.g. `"Acme <no-reply@acme.wtfalch.dev>"` or a bare address. */
6
6
  readonly from: string;
7
7
  readonly replyTo?: string;
8
+ readonly attachments?: readonly Attachment[];
8
9
  }
9
10
  export interface Transport {
10
11
  send(message: OutgoingMail): Promise<SendResult>;
@@ -36,6 +37,14 @@ export interface ResendLike {
36
37
  html: string;
37
38
  text: string;
38
39
  reply_to?: string;
40
+ /** Resend's own field names: `content` is base64 (or a Buffer), `content_type` is
41
+ * optional and derived from `filename` when absent --
42
+ * https://resend.com/docs/api-reference/emails/send-email. */
43
+ attachments?: Array<{
44
+ filename: string;
45
+ content: string;
46
+ content_type?: string;
47
+ }>;
39
48
  }): Promise<{
40
49
  data: {
41
50
  id: string;
@@ -55,9 +64,116 @@ export interface ResendTransportOptions {
55
64
  /**
56
65
  * Sends over Resend. The one place in the estate that imports the `resend`
57
66
  * package (README: "This package is the one place that knows about
58
- * Resend").
67
+ * Resend") -- and, since [0006](../docs/adr/0006-lettermint-transport-resend-optional-peer.md),
68
+ * it imports it lazily: `resend` is now an optional peer, not a runtime
69
+ * dependency, so a consumer that only ever passes `client` (every test in
70
+ * this repo, and any caller that only sends over Lettermint) never needs
71
+ * the package installed at all. The `import('resend')` below runs at most
72
+ * once per transport, on the first `send()` call, only when no `client` was
73
+ * given.
59
74
  */
60
75
  export declare function createResendTransport(options: ResendTransportOptions): Transport;
76
+ /**
77
+ * The minimal shape this package calls on `fetch` to speak Lettermint's
78
+ * Sending API directly -- the same no-SDK, injectable-`fetch` posture
79
+ * `@wtfalch/payments`'s Stripe/Vipps adapters take (payments ADR 0003,
80
+ * `packages/payments/src/http.ts`'s `FetchLike`). No `lettermint` npm
81
+ * package is a dependency of this package.
82
+ */
83
+ export type LettermintFetchLike = (url: string, init: {
84
+ method: string;
85
+ headers: Record<string, string>;
86
+ body: string;
87
+ }) => Promise<{
88
+ ok: boolean;
89
+ status: number;
90
+ json(): Promise<unknown>;
91
+ }>;
92
+ export interface LettermintAttachment {
93
+ readonly filename: string;
94
+ /**
95
+ * Snake-case, matching the Sending API's own wire field exactly --
96
+ * unlike `ResendLike`, `LettermintSendPayload` has no translation step
97
+ * before `JSON.stringify` (`createLettermintClient`'s `send`), so this
98
+ * interface's own field names ARE the request body's JSON keys.
99
+ */
100
+ readonly content_type?: string;
101
+ /**
102
+ * Base64-encoded attachment bytes. The Sending API's own OpenAPI spec
103
+ * gives `content` no encoding note beyond its type (`string`), but every
104
+ * official SDK base64-encodes before setting it, and the inline-images
105
+ * guide states it explicitly for that sibling field:
106
+ * https://lettermint.co/docs/guides/send-email-with-nodejs,
107
+ * https://lettermint.co/docs/platform/emails/inline-images.
108
+ */
109
+ readonly content: string;
110
+ }
111
+ /** The Sending API's own request shape for `POST /send`, per
112
+ * https://lettermint.co/docs/api-reference/sending/0.0.1/lettermint-sending-openapi.json. */
113
+ export interface LettermintSendPayload {
114
+ readonly from: string;
115
+ readonly to: readonly string[];
116
+ readonly subject: string;
117
+ readonly html?: string;
118
+ readonly text?: string;
119
+ readonly reply_to?: readonly string[];
120
+ readonly attachments?: readonly LettermintAttachment[];
121
+ }
122
+ /**
123
+ * The minimal shape this package calls on a Lettermint client: `send`
124
+ * only, the same structural-injection pattern `ResendLike` uses. The
125
+ * default client (built from `apiKey`) satisfies this over plain `fetch`;
126
+ * a fake with no network access is what this package's own tests inject.
127
+ */
128
+ export interface LettermintLike {
129
+ send(payload: LettermintSendPayload): Promise<{
130
+ message_id: string | null;
131
+ status: string;
132
+ }>;
133
+ }
134
+ export interface LettermintTransportOptions {
135
+ /** The org's Lettermint Project API token (`lm_...`), resolved by the
136
+ * caller from `@wtfalch/keys`; mailer never fetches or stores it. */
137
+ readonly apiKey?: string;
138
+ /** A client to use instead of constructing one from `apiKey` -- how tests inject a fake. */
139
+ readonly client?: LettermintLike;
140
+ /** Injectable `fetch`, used only when building the default client from
141
+ * `apiKey`. Defaults to `globalThis.fetch`. */
142
+ readonly fetch?: LettermintFetchLike;
143
+ }
144
+ /**
145
+ * Thrown when Lettermint refuses to send because the `from` domain is not
146
+ * one the sending project may use -- either unverified, or verified but
147
+ * restricted to other Lettermint projects on the team (a Lettermint domain
148
+ * is unrestricted across a team by default, per foundry's own Lettermint
149
+ * research, `.plans/2026-09-28-lettermint-api.md` §2/§6, so this refusal is
150
+ * real and opt-in, not the common case). Lettermint's own confirmed wording
151
+ * for the restriction case: "The domain 'mail.acme.com' is not available
152
+ * for use by this project." --
153
+ * https://lettermint.co/docs/platform/domains/project-limits. A caller
154
+ * (foundry's provisioning retry, or an org's own admin UI) needs to tell
155
+ * this refusal apart from a transient failure, hence a typed error rather
156
+ * than the generic one below.
157
+ */
158
+ export declare class LettermintDomainRefusedError extends Error {
159
+ constructor(message: string);
160
+ }
161
+ /**
162
+ * Sends over Lettermint (EU, GDPR -- William's 2026-09-28 choice over
163
+ * Resend, see [0006](../docs/adr/0006-lettermint-transport-resend-optional-peer.md)),
164
+ * via plain `fetch` against Lettermint's Sending API. Base URL, auth header
165
+ * and request/response shapes:
166
+ * https://lettermint.co/docs/api-reference/sending/0.0.1/lettermint-sending-openapi.json
167
+ * (confirmed by direct fetch of that spec, and cross-checked against
168
+ * foundry's own Lettermint research, `.plans/2026-09-28-lettermint-api.md`
169
+ * §1: base URL `https://api.lettermint.co/v1`, Sending API auth header
170
+ * `x-lettermint-token: <project_token>`, a Project API token prefixed
171
+ * `lm_...`). Same no-SDK, `fetch`-injection posture as
172
+ * `@wtfalch/payments` (payments ADR 0003) -- no `lettermint` npm package is
173
+ * a dependency of this package, so this transport's own tests never touch
174
+ * the network.
175
+ */
176
+ export declare function createLettermintTransport(options: LettermintTransportOptions): Transport;
61
177
  /**
62
178
  * `dev`, unconditionally, outside `NODE_ENV=production`. In production
63
179
  * there is no default: `send()` must be given an explicit transport,
package/dist/transport.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { Resend } from 'resend';
5
4
  import { MailerConfigError } from './types.js';
6
5
  /**
7
6
  * Writes every message to `<dir>/<timestamp>-<id>.json` instead of sending
@@ -24,15 +23,28 @@ export function createDevTransport(options = {}) {
24
23
  /**
25
24
  * Sends over Resend. The one place in the estate that imports the `resend`
26
25
  * package (README: "This package is the one place that knows about
27
- * Resend").
26
+ * Resend") -- and, since [0006](../docs/adr/0006-lettermint-transport-resend-optional-peer.md),
27
+ * it imports it lazily: `resend` is now an optional peer, not a runtime
28
+ * dependency, so a consumer that only ever passes `client` (every test in
29
+ * this repo, and any caller that only sends over Lettermint) never needs
30
+ * the package installed at all. The `import('resend')` below runs at most
31
+ * once per transport, on the first `send()` call, only when no `client` was
32
+ * given.
28
33
  */
29
34
  export function createResendTransport(options) {
30
35
  if (!options.client && !options.apiKey) {
31
36
  throw new MailerConfigError('createResendTransport needs either "apiKey" or "client".');
32
37
  }
33
- const client = options.client ?? new Resend(options.apiKey);
38
+ let resolvedClient;
39
+ function resolveClient() {
40
+ if (options.client)
41
+ return Promise.resolve(options.client);
42
+ resolvedClient ??= import('resend').then(({ Resend }) => new Resend(options.apiKey));
43
+ return resolvedClient;
44
+ }
34
45
  return {
35
46
  async send(message) {
47
+ const client = await resolveClient();
36
48
  const { data, error } = await client.emails.send({
37
49
  from: message.from,
38
50
  to: message.to,
@@ -40,6 +52,15 @@ export function createResendTransport(options) {
40
52
  html: message.html,
41
53
  text: message.text,
42
54
  ...(message.replyTo ? { reply_to: message.replyTo } : {}),
55
+ ...(message.attachments
56
+ ? {
57
+ attachments: message.attachments.map((attachment) => ({
58
+ filename: attachment.filename,
59
+ content: attachment.content,
60
+ ...(attachment.contentType ? { content_type: attachment.contentType } : {}),
61
+ })),
62
+ }
63
+ : {}),
43
64
  });
44
65
  if (error)
45
66
  throw new Error(`mailer: resend refused the message: ${error.message}`);
@@ -49,6 +70,107 @@ export function createResendTransport(options) {
49
70
  },
50
71
  };
51
72
  }
73
+ /**
74
+ * Thrown when Lettermint refuses to send because the `from` domain is not
75
+ * one the sending project may use -- either unverified, or verified but
76
+ * restricted to other Lettermint projects on the team (a Lettermint domain
77
+ * is unrestricted across a team by default, per foundry's own Lettermint
78
+ * research, `.plans/2026-09-28-lettermint-api.md` §2/§6, so this refusal is
79
+ * real and opt-in, not the common case). Lettermint's own confirmed wording
80
+ * for the restriction case: "The domain 'mail.acme.com' is not available
81
+ * for use by this project." --
82
+ * https://lettermint.co/docs/platform/domains/project-limits. A caller
83
+ * (foundry's provisioning retry, or an org's own admin UI) needs to tell
84
+ * this refusal apart from a transient failure, hence a typed error rather
85
+ * than the generic one below.
86
+ */
87
+ export class LettermintDomainRefusedError extends Error {
88
+ constructor(message) {
89
+ super(message);
90
+ this.name = 'LettermintDomainRefusedError';
91
+ }
92
+ }
93
+ const LETTERMINT_SEND_URL = 'https://api.lettermint.co/v1/send';
94
+ /**
95
+ * Matches Lettermint's confirmed domain-restriction refusal message
96
+ * (`LettermintDomainRefusedError`'s own doc comment has the exact quote and
97
+ * source) and the same shape of wording for an unverified domain. The
98
+ * unverified-domain case is inferred, not independently confirmed against
99
+ * a live error response -- the fetched docs and OpenAPI spec are silent on
100
+ * its exact status code and message (`.plans/2026-09-28-lettermint-api.md`
101
+ * §6, "unknown -- no fetched page states or shows an error example for
102
+ * that case").
103
+ */
104
+ const DOMAIN_REFUSAL = /\bdomain\b.*\b(not available|not verified|not allowed)/i;
105
+ function defaultLettermintFetch() {
106
+ if (typeof globalThis.fetch !== 'function') {
107
+ throw new MailerConfigError('mailer: no global fetch available; pass { fetch } explicitly to createLettermintTransport.');
108
+ }
109
+ return globalThis.fetch.bind(globalThis);
110
+ }
111
+ function createLettermintClient(apiKey, fetchImpl) {
112
+ return {
113
+ async send(payload) {
114
+ const response = await fetchImpl(LETTERMINT_SEND_URL, {
115
+ method: 'POST',
116
+ headers: { 'x-lettermint-token': apiKey, 'content-type': 'application/json' },
117
+ body: JSON.stringify(payload),
118
+ });
119
+ const body = (await response.json());
120
+ if (!response.ok) {
121
+ const message = body.message ?? `lettermint answered ${response.status} with no message`;
122
+ if (DOMAIN_REFUSAL.test(message))
123
+ throw new LettermintDomainRefusedError(message);
124
+ throw new Error(`mailer: lettermint refused the message: ${message}`);
125
+ }
126
+ return { message_id: body.message_id ?? null, status: body.status ?? 'unknown' };
127
+ },
128
+ };
129
+ }
130
+ /**
131
+ * Sends over Lettermint (EU, GDPR -- William's 2026-09-28 choice over
132
+ * Resend, see [0006](../docs/adr/0006-lettermint-transport-resend-optional-peer.md)),
133
+ * via plain `fetch` against Lettermint's Sending API. Base URL, auth header
134
+ * and request/response shapes:
135
+ * https://lettermint.co/docs/api-reference/sending/0.0.1/lettermint-sending-openapi.json
136
+ * (confirmed by direct fetch of that spec, and cross-checked against
137
+ * foundry's own Lettermint research, `.plans/2026-09-28-lettermint-api.md`
138
+ * §1: base URL `https://api.lettermint.co/v1`, Sending API auth header
139
+ * `x-lettermint-token: <project_token>`, a Project API token prefixed
140
+ * `lm_...`). Same no-SDK, `fetch`-injection posture as
141
+ * `@wtfalch/payments` (payments ADR 0003) -- no `lettermint` npm package is
142
+ * a dependency of this package, so this transport's own tests never touch
143
+ * the network.
144
+ */
145
+ export function createLettermintTransport(options) {
146
+ if (!options.client && !options.apiKey) {
147
+ throw new MailerConfigError('createLettermintTransport needs either "apiKey" or "client".');
148
+ }
149
+ const client = options.client ??
150
+ createLettermintClient(options.apiKey, options.fetch ?? defaultLettermintFetch());
151
+ return {
152
+ async send(message) {
153
+ const { message_id, status } = await client.send({
154
+ from: message.from,
155
+ to: [message.to],
156
+ subject: message.subject,
157
+ html: message.html,
158
+ text: message.text,
159
+ ...(message.replyTo ? { reply_to: [message.replyTo] } : {}),
160
+ ...(message.attachments
161
+ ? {
162
+ attachments: message.attachments.map((attachment) => ({
163
+ filename: attachment.filename,
164
+ content: attachment.content,
165
+ ...(attachment.contentType ? { content_type: attachment.contentType } : {}),
166
+ })),
167
+ }
168
+ : {}),
169
+ });
170
+ return { id: message_id ?? status, transport: 'lettermint' };
171
+ },
172
+ };
173
+ }
52
174
  /**
53
175
  * `dev`, unconditionally, outside `NODE_ENV=production`. In production
54
176
  * there is no default: `send()` must be given an explicit transport,
package/dist/types.d.ts CHANGED
@@ -10,15 +10,48 @@ export interface Brand {
10
10
  readonly logoUrl?: string;
11
11
  readonly primaryColor?: string;
12
12
  }
13
+ /**
14
+ * Every language a template ships copy for. `en` ships first and is the
15
+ * fallback every template's own `WORDS`-style table falls back to when
16
+ * `locale` is unset or not one of these -- the same fallback shape the auth
17
+ * broker's own `compose()` already uses (`auth/broker/src/mail.ts`:
18
+ * `WORDS[sender.locale] ?? WORDS.en`). Adding a third language is a
19
+ * template-by-template change, not an API change, so it is not locked here.
20
+ */
21
+ export type Locale = 'en' | 'nb';
22
+ /** `send()`'s own default when `from.locale` is unset. */
23
+ export declare const DEFAULT_LOCALE: Locale;
13
24
  /**
14
25
  * Who a message is from, per organisation. The host resolves the domain,
15
26
  * from address and reply-to (its own org settings, or a default); mailer
16
27
  * never stores or looks one up -- it has no store (see README, "No store").
28
+ *
29
+ * `locale` picks which language's copy a template renders, with an English
30
+ * fallback (`DEFAULT_LOCALE`) -- the same field the auth broker's own
31
+ * `Sender` already carries (`auth/broker/src/mail.ts`), so moving a caller
32
+ * from the broker's `compose()` to mailer's `send()` is a like-for-like
33
+ * swap. Placed on `SenderIdentity` rather than on `send()`'s own options
34
+ * because every template call already threads `from` through, and an org's
35
+ * outbound language is a property of who is sending, not of one message.
17
36
  */
18
37
  export interface SenderIdentity {
19
38
  readonly fromAddress: string;
20
39
  readonly fromName?: string;
21
40
  readonly replyTo?: string;
41
+ readonly locale?: Locale;
42
+ }
43
+ /**
44
+ * One file attached to a send. `content` is base64-encoded bytes -- the
45
+ * convention both transports' real APIs use (Resend:
46
+ * https://resend.com/docs/api-reference/emails/send-email, "Content of an
47
+ * attached file, passed as a buffer or Base64 string"; Lettermint's Sending
48
+ * API OpenAPI spec, `attachments[].content`, and its official SDKs, which
49
+ * all base64-encode before setting it).
50
+ */
51
+ export interface Attachment {
52
+ readonly filename: string;
53
+ readonly contentType?: string;
54
+ readonly content: string;
22
55
  }
23
56
  /** One rendered message: subject, HTML, and a required plain-text part. */
24
57
  export interface MailMessage {
@@ -29,7 +62,7 @@ export interface MailMessage {
29
62
  /** What a transport returns once it has accepted a message. */
30
63
  export interface SendResult {
31
64
  readonly id: string;
32
- readonly transport: 'resend' | 'dev';
65
+ readonly transport: 'resend' | 'dev' | 'lettermint';
33
66
  }
34
67
  /**
35
68
  * `send()` has no usable default (no transport given, and either
package/dist/types.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** `send()`'s own default when `from.locale` is unset. */
2
+ export const DEFAULT_LOCALE = 'en';
1
3
  /**
2
4
  * `send()` has no usable default (no transport given, and either
3
5
  * production with no explicit transport, or a Resend transport built with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/mailer",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "One send(template, to, data) call for the estate's transactional email, over Resend, with a dev transport that writes to disk instead of sending.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,11 +9,7 @@
9
9
  },
10
10
  "license": "MIT",
11
11
  "type": "module",
12
- "files": [
13
- "dist",
14
- "README.md",
15
- "LICENSE"
16
- ],
12
+ "files": ["dist", "README.md", "LICENSE"],
17
13
  "exports": {
18
14
  ".": {
19
15
  "types": "./dist/index.d.ts",
@@ -28,17 +24,27 @@
28
24
  "engines": {
29
25
  "node": ">=22.0.0"
30
26
  },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.build.json",
29
+ "typecheck": "tsc --noEmit",
30
+ "test": "vitest run",
31
+ "prepack": "pnpm build"
32
+ },
31
33
  "dependencies": {
34
+ "@wtfalch/utils": "0.1.0"
35
+ },
36
+ "peerDependencies": {
32
37
  "resend": "^4.1.0"
33
38
  },
39
+ "peerDependenciesMeta": {
40
+ "resend": {
41
+ "optional": true
42
+ }
43
+ },
34
44
  "devDependencies": {
35
45
  "@types/node": "^22",
46
+ "resend": "^4.1.0",
36
47
  "typescript": "^5.9.0",
37
48
  "vitest": "^4.1.6"
38
- },
39
- "scripts": {
40
- "build": "tsc -p tsconfig.build.json",
41
- "typecheck": "tsc --noEmit",
42
- "test": "vitest run"
43
49
  }
44
- }
50
+ }