@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 +72 -3
- package/dist/header-safe.d.ts +6 -0
- package/dist/header-safe.js +31 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +3 -2
- package/dist/layout.d.ts +4 -5
- package/dist/layout.js +7 -12
- package/dist/send.d.ts +3 -1
- package/dist/send.js +7 -4
- package/dist/templates/account.d.ts +34 -0
- package/dist/templates/account.js +171 -0
- package/dist/templates/index.d.ts +11 -4
- package/dist/templates/index.js +10 -3
- package/dist/templates/invitation.d.ts +2 -2
- package/dist/templates/invitation.js +32 -10
- package/dist/transport.d.ts +118 -2
- package/dist/transport.js +125 -3
- package/dist/types.d.ts +34 -1
- package/dist/types.js +2 -0
- package/package.json +18 -12
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
|
-
|
|
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, '&')
|
|
8
|
-
.replace(/</g, '<')
|
|
9
|
-
.replace(/>/g, '>')
|
|
10
|
-
.replace(/"/g, '"');
|
|
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
|
|
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
|
|
5
|
-
*
|
|
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;
|
package/dist/templates/index.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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">${
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
37
|
+
words.textIntro(data.inviterName, data.organisationName, data.roleName),
|
|
16
38
|
'',
|
|
17
|
-
|
|
39
|
+
`${words.textAccept}: ${data.acceptUrl}`,
|
|
18
40
|
].join('\n');
|
|
19
41
|
return layout({ subject, bodyHtml, bodyText: text, brand: data.brand });
|
|
20
42
|
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/mailer",
|
|
3
|
-
"version": "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
|
+
}
|