@wtfalch/mailer 0.3.0 → 0.5.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 +19 -17
- package/dist/header-safe.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/layout.d.ts +1 -1
- package/dist/layout.js +1 -1
- package/dist/transport.d.ts +22 -65
- package/dist/transport.js +54 -65
- package/dist/types.d.ts +4 -6
- package/dist/types.js +1 -1
- package/package.json +2 -11
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @wtfalch/mailer
|
|
2
2
|
|
|
3
3
|
One `send(template, to, data)` call for the estate's transactional email,
|
|
4
|
-
over
|
|
4
|
+
over Lettermint, with a dev transport that writes to disk instead of sending.
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
|
|
@@ -28,9 +28,9 @@ await send({
|
|
|
28
28
|
brand: { name: 'Acme' }, // optional
|
|
29
29
|
},
|
|
30
30
|
from: {
|
|
31
|
-
fromAddress: 'no-reply@acme.
|
|
31
|
+
fromAddress: 'no-reply@acme.example.com',
|
|
32
32
|
fromName: 'Acme', // optional
|
|
33
|
-
replyTo: 'support@acme.
|
|
33
|
+
replyTo: 'support@acme.example.com', // optional
|
|
34
34
|
locale: 'nb', // optional, defaults to 'en' -- picks which language's copy renders
|
|
35
35
|
},
|
|
36
36
|
attachments: [ // optional
|
|
@@ -45,9 +45,19 @@ await send({
|
|
|
45
45
|
missing transport fails loudly instead of silently no-op'ing or making a
|
|
46
46
|
live call with no key.
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
Two env names change that default, in every `NODE_ENV`: with
|
|
49
|
+
`LETTERMINT_API_URL` set (a base URL such as `https://sink.example.com/v1`;
|
|
50
|
+
https, or http for `localhost`, `127.0.0.1` or `[::1]` only) `send()` sends
|
|
51
|
+
through Lettermint's API shape to that server, with the token from
|
|
52
|
+
`LETTERMINT_API_TOKEN` in the `x-lettermint-token` header. This is how a
|
|
53
|
+
stage app, a production build holding real addresses, sends every mail to a
|
|
54
|
+
sink. A malformed URL, or a URL with no token, throws `MailerConfigError`
|
|
55
|
+
when the default transport is built, before any send. The same URL is
|
|
56
|
+
available per transport as `createLettermintTransport({ apiKey, baseUrl })`.
|
|
57
|
+
|
|
58
|
+
A held key: however you resolve `apiKey` for `createLettermintTransport`,
|
|
49
59
|
mailer never fetches or stores it. In the estate, the caller resolves the
|
|
50
|
-
org's
|
|
60
|
+
org's Lettermint key as a held key from `@wtfalch/keys` and passes it in on
|
|
51
61
|
every call.
|
|
52
62
|
|
|
53
63
|
### Locale
|
|
@@ -60,22 +70,14 @@ already uses (`WORDS[sender.locale] ?? WORDS.en`).
|
|
|
60
70
|
|
|
61
71
|
### Transports
|
|
62
72
|
|
|
63
|
-
A held key: however you resolve `apiKey` for
|
|
73
|
+
A held key: however you resolve `apiKey` for the transport below, mailer
|
|
64
74
|
never fetches or stores it. In the estate, the caller resolves the org's
|
|
65
|
-
|
|
75
|
+
Lettermint key as a held key from `@wtfalch/keys` and passes it
|
|
66
76
|
in on every call.
|
|
67
77
|
|
|
68
78
|
- `createDevTransport({ dir })` -- writes each message to
|
|
69
79
|
`<dir>/<timestamp>-<id>.json` (default `dir`: `.mailer/dev`). Never
|
|
70
80
|
opens a network connection.
|
|
71
|
-
- `createResendTransport({ apiKey })` -- sends over Resend. Needs either
|
|
72
|
-
`apiKey` (a plain string -- resolve it yourself, e.g. from
|
|
73
|
-
`@wtfalch/keys`) or `client`, a minimal structural `ResendLike` for
|
|
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
81
|
- `createLettermintTransport({ apiKey })` -- sends over Lettermint's
|
|
80
82
|
Sending API (EU-hosted). Needs either `apiKey` (the org's Lettermint
|
|
81
83
|
Project API token, `lm_...` -- resolve it yourself, e.g. from
|
|
@@ -126,7 +128,7 @@ await send({
|
|
|
126
128
|
template: 'verify',
|
|
127
129
|
to: 'person@example.com',
|
|
128
130
|
data: { productName: 'Acme', url: 'https://app.example.com/verify/abc', code: '482913' },
|
|
129
|
-
from: { fromAddress: 'no-reply@acme.
|
|
131
|
+
from: { fromAddress: 'no-reply@acme.example.com', locale: 'nb' },
|
|
130
132
|
});
|
|
131
133
|
```
|
|
132
134
|
|
|
@@ -142,5 +144,5 @@ From `packages/mailer`:
|
|
|
142
144
|
pnpm test
|
|
143
145
|
```
|
|
144
146
|
|
|
145
|
-
Tests run against the dev transport and a fake `
|
|
147
|
+
Tests run against the dev transport and a fake `LettermintLike` client --
|
|
146
148
|
never a live key, never the network.
|
package/dist/header-safe.js
CHANGED
|
@@ -7,7 +7,7 @@ function isControlCharCode(code) {
|
|
|
7
7
|
* True if `value` contains a CR, LF or any other control character. Checked
|
|
8
8
|
* by character code rather than a regex, since a header-bound field is
|
|
9
9
|
* built in part from caller-supplied strings (an inviter's name, an org's
|
|
10
|
-
* name) and this package must not depend on
|
|
10
|
+
* name) and this package must not depend on the mail provider's own defence against
|
|
11
11
|
* header injection to reject them (see issue #3).
|
|
12
12
|
*/
|
|
13
13
|
function hasControlCharacter(value) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type { Attachment, Brand, Locale, MailMessage, SenderIdentity, SendResult, } from './types.js';
|
|
2
2
|
export { DEFAULT_LOCALE, MailerConfigError } from './types.js';
|
|
3
|
-
export type { DevTransportOptions, LettermintAttachment, LettermintFetchLike, LettermintLike, LettermintSendPayload, LettermintTransportOptions, OutgoingMail,
|
|
4
|
-
export { createDevTransport, createLettermintTransport,
|
|
3
|
+
export type { DevTransportOptions, LettermintAttachment, LettermintFetchLike, LettermintLike, LettermintSendPayload, LettermintTransportOptions, OutgoingMail, Transport, } from './transport.js';
|
|
4
|
+
export { createDevTransport, createLettermintTransport, 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';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { DEFAULT_LOCALE, MailerConfigError } from './types.js';
|
|
2
|
-
export { createDevTransport, createLettermintTransport,
|
|
2
|
+
export { createDevTransport, createLettermintTransport, 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';
|
package/dist/layout.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface LayoutOptions {
|
|
|
23
23
|
* given, except for `brand.name`, which it escapes itself.
|
|
24
24
|
*
|
|
25
25
|
* `subject` is checked for CR/LF and other control characters before it is
|
|
26
|
-
* returned, since it is not HTML-escaped and reaches
|
|
26
|
+
* returned, since it is not HTML-escaped and reaches the mail provider as a header
|
|
27
27
|
* value -- see `header-safe.ts`.
|
|
28
28
|
*/
|
|
29
29
|
export declare function layout(options: LayoutOptions): MailMessage;
|
package/dist/layout.js
CHANGED
|
@@ -20,7 +20,7 @@ const BRAND_STYLE = 'margin:0 0 20px;font-size:13px;color:#545b66;text-transform
|
|
|
20
20
|
* given, except for `brand.name`, which it escapes itself.
|
|
21
21
|
*
|
|
22
22
|
* `subject` is checked for CR/LF and other control characters before it is
|
|
23
|
-
* returned, since it is not HTML-escaped and reaches
|
|
23
|
+
* returned, since it is not HTML-escaped and reaches the mail provider as a header
|
|
24
24
|
* value -- see `header-safe.ts`.
|
|
25
25
|
*/
|
|
26
26
|
export function layout(options) {
|
package/dist/transport.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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
|
-
/** Already formatted, e.g. `"Acme <no-reply@acme.
|
|
5
|
+
/** Already formatted, e.g. `"Acme <no-reply@acme.example.com>"` or a bare address. */
|
|
6
6
|
readonly from: string;
|
|
7
7
|
readonly replyTo?: string;
|
|
8
8
|
readonly attachments?: readonly Attachment[];
|
|
@@ -16,63 +16,10 @@ export interface DevTransportOptions {
|
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
18
|
* Writes every message to `<dir>/<timestamp>-<id>.json` instead of sending
|
|
19
|
-
* it. Never opens a network connection
|
|
20
|
-
*
|
|
21
|
-
* nothing that sends mail could be tested without a live key.
|
|
19
|
+
* it. Never opens a network connection, so nothing that sends mail needs
|
|
20
|
+
* a live key to be tested.
|
|
22
21
|
*/
|
|
23
22
|
export declare function createDevTransport(options?: DevTransportOptions): Transport;
|
|
24
|
-
/**
|
|
25
|
-
* The minimal shape this package calls on a Resend client: `emails.send`
|
|
26
|
-
* only, with the fields mailer actually sets. The real `Resend` class
|
|
27
|
-
* satisfies this structurally, and so does a fake with no network access,
|
|
28
|
-
* which is what this package's own tests inject (never a real key, never a
|
|
29
|
-
* real send -- see `transport.test.ts`).
|
|
30
|
-
*/
|
|
31
|
-
export interface ResendLike {
|
|
32
|
-
emails: {
|
|
33
|
-
send(payload: {
|
|
34
|
-
from: string;
|
|
35
|
-
to: string;
|
|
36
|
-
subject: string;
|
|
37
|
-
html: string;
|
|
38
|
-
text: string;
|
|
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
|
-
}>;
|
|
48
|
-
}): Promise<{
|
|
49
|
-
data: {
|
|
50
|
-
id: string;
|
|
51
|
-
} | null;
|
|
52
|
-
error: {
|
|
53
|
-
message: string;
|
|
54
|
-
} | null;
|
|
55
|
-
}>;
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
export interface ResendTransportOptions {
|
|
59
|
-
/** The Resend API key. The caller resolves this from `@wtfalch/keys`; mailer never fetches or stores it. */
|
|
60
|
-
readonly apiKey?: string;
|
|
61
|
-
/** A client to use instead of constructing one from `apiKey` -- how tests inject a fake. */
|
|
62
|
-
readonly client?: ResendLike;
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Sends over Resend. The one place in the estate that imports the `resend`
|
|
66
|
-
* package (README: "This package is the one place that knows about
|
|
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.
|
|
74
|
-
*/
|
|
75
|
-
export declare function createResendTransport(options: ResendTransportOptions): Transport;
|
|
76
23
|
/**
|
|
77
24
|
* The minimal shape this package calls on `fetch` to speak Lettermint's
|
|
78
25
|
* Sending API directly -- the same no-SDK, injectable-`fetch` posture
|
|
@@ -84,6 +31,7 @@ export type LettermintFetchLike = (url: string, init: {
|
|
|
84
31
|
method: string;
|
|
85
32
|
headers: Record<string, string>;
|
|
86
33
|
body: string;
|
|
34
|
+
signal?: AbortSignal;
|
|
87
35
|
}) => Promise<{
|
|
88
36
|
ok: boolean;
|
|
89
37
|
status: number;
|
|
@@ -93,7 +41,7 @@ export interface LettermintAttachment {
|
|
|
93
41
|
readonly filename: string;
|
|
94
42
|
/**
|
|
95
43
|
* Snake-case, matching the Sending API's own wire field exactly --
|
|
96
|
-
*
|
|
44
|
+
* `LettermintSendPayload` has no translation step
|
|
97
45
|
* before `JSON.stringify` (`createLettermintClient`'s `send`), so this
|
|
98
46
|
* interface's own field names ARE the request body's JSON keys.
|
|
99
47
|
*/
|
|
@@ -121,7 +69,7 @@ export interface LettermintSendPayload {
|
|
|
121
69
|
}
|
|
122
70
|
/**
|
|
123
71
|
* The minimal shape this package calls on a Lettermint client: `send`
|
|
124
|
-
* only,
|
|
72
|
+
* only, a structural interface so a test can inject a fake. The
|
|
125
73
|
* default client (built from `apiKey`) satisfies this over plain `fetch`;
|
|
126
74
|
* a fake with no network access is what this package's own tests inject.
|
|
127
75
|
*/
|
|
@@ -140,6 +88,12 @@ export interface LettermintTransportOptions {
|
|
|
140
88
|
/** Injectable `fetch`, used only when building the default client from
|
|
141
89
|
* `apiKey`. Defaults to `globalThis.fetch`. */
|
|
142
90
|
readonly fetch?: LettermintFetchLike;
|
|
91
|
+
/** The Sending API base URL, without `/send`. Default
|
|
92
|
+
* `https://api.lettermint.co/v1`. Must be https, except for a local sink
|
|
93
|
+
* host (`localhost`, `127.0.0.1`, `[::1]`), so a misconfigured value
|
|
94
|
+
* cannot send mail or the token over plain http. Ignored when `client`
|
|
95
|
+
* is given. */
|
|
96
|
+
readonly baseUrl?: string;
|
|
143
97
|
}
|
|
144
98
|
/**
|
|
145
99
|
* Thrown when Lettermint refuses to send because the `from` domain is not
|
|
@@ -159,8 +113,8 @@ export declare class LettermintDomainRefusedError extends Error {
|
|
|
159
113
|
constructor(message: string);
|
|
160
114
|
}
|
|
161
115
|
/**
|
|
162
|
-
* Sends over Lettermint (EU, GDPR
|
|
163
|
-
*
|
|
116
|
+
* Sends over Lettermint (EU, GDPR; see
|
|
117
|
+
* [0006](../docs/adr/0006-lettermint-transport.md)),
|
|
164
118
|
* via plain `fetch` against Lettermint's Sending API. Base URL, auth header
|
|
165
119
|
* and request/response shapes:
|
|
166
120
|
* https://lettermint.co/docs/api-reference/sending/0.0.1/lettermint-sending-openapi.json
|
|
@@ -175,10 +129,13 @@ export declare class LettermintDomainRefusedError extends Error {
|
|
|
175
129
|
*/
|
|
176
130
|
export declare function createLettermintTransport(options: LettermintTransportOptions): Transport;
|
|
177
131
|
/**
|
|
178
|
-
* `
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
132
|
+
* When `LETTERMINT_API_URL` is set (in any `NODE_ENV`), returns a Lettermint
|
|
133
|
+
* transport that sends to that base URL with the token in
|
|
134
|
+
* `LETTERMINT_API_TOKEN`: how a stage app, a production build holding real
|
|
135
|
+
* addresses, sends every mail to a sink server. A malformed URL, or a URL
|
|
136
|
+
* with no token, throws `MailerConfigError` here, before any send.
|
|
137
|
+
*
|
|
138
|
+
* Otherwise: `dev` outside `NODE_ENV=production`; in production there is no
|
|
139
|
+
* default, and `send()` must be given an explicit transport.
|
|
183
140
|
*/
|
|
184
141
|
export declare function defaultTransport(): Transport;
|
package/dist/transport.js
CHANGED
|
@@ -4,9 +4,8 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { MailerConfigError } from './types.js';
|
|
5
5
|
/**
|
|
6
6
|
* Writes every message to `<dir>/<timestamp>-<id>.json` instead of sending
|
|
7
|
-
* it. Never opens a network connection
|
|
8
|
-
*
|
|
9
|
-
* nothing that sends mail could be tested without a live key.
|
|
7
|
+
* it. Never opens a network connection, so nothing that sends mail needs
|
|
8
|
+
* a live key to be tested.
|
|
10
9
|
*/
|
|
11
10
|
export function createDevTransport(options = {}) {
|
|
12
11
|
const dir = options.dir ?? '.mailer/dev';
|
|
@@ -20,56 +19,6 @@ export function createDevTransport(options = {}) {
|
|
|
20
19
|
},
|
|
21
20
|
};
|
|
22
21
|
}
|
|
23
|
-
/**
|
|
24
|
-
* Sends over Resend. The one place in the estate that imports the `resend`
|
|
25
|
-
* package (README: "This package is the one place that knows about
|
|
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.
|
|
33
|
-
*/
|
|
34
|
-
export function createResendTransport(options) {
|
|
35
|
-
if (!options.client && !options.apiKey) {
|
|
36
|
-
throw new MailerConfigError('createResendTransport needs either "apiKey" or "client".');
|
|
37
|
-
}
|
|
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
|
-
}
|
|
45
|
-
return {
|
|
46
|
-
async send(message) {
|
|
47
|
-
const client = await resolveClient();
|
|
48
|
-
const { data, error } = await client.emails.send({
|
|
49
|
-
from: message.from,
|
|
50
|
-
to: message.to,
|
|
51
|
-
subject: message.subject,
|
|
52
|
-
html: message.html,
|
|
53
|
-
text: message.text,
|
|
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
|
-
: {}),
|
|
64
|
-
});
|
|
65
|
-
if (error)
|
|
66
|
-
throw new Error(`mailer: resend refused the message: ${error.message}`);
|
|
67
|
-
if (!data)
|
|
68
|
-
throw new Error('mailer: resend returned no data and no error');
|
|
69
|
-
return { id: data.id, transport: 'resend' };
|
|
70
|
-
},
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
22
|
/**
|
|
74
23
|
* Thrown when Lettermint refuses to send because the `from` domain is not
|
|
75
24
|
* one the sending project may use -- either unverified, or verified but
|
|
@@ -90,7 +39,33 @@ export class LettermintDomainRefusedError extends Error {
|
|
|
90
39
|
this.name = 'LettermintDomainRefusedError';
|
|
91
40
|
}
|
|
92
41
|
}
|
|
93
|
-
const
|
|
42
|
+
const LETTERMINT_BASE_URL = 'https://api.lettermint.co/v1';
|
|
43
|
+
const LETTERMINT_TIMEOUT_MS = 30_000;
|
|
44
|
+
const LOCAL_SINK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
45
|
+
/**
|
|
46
|
+
* Validates a Sending API base URL where it is read and returns it without a
|
|
47
|
+
* trailing slash. Rule: https, or http only for a local sink host. Anything
|
|
48
|
+
* else throws `MailerConfigError`, so a bad value fails at start, not at the
|
|
49
|
+
* first send.
|
|
50
|
+
*/
|
|
51
|
+
function parseBaseUrl(raw, source) {
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(raw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new MailerConfigError(`mailer: ${source} is not a valid URL.`);
|
|
58
|
+
}
|
|
59
|
+
// A single-label host (a container name such as `acme-stage-sink`) only resolves on the internal network.
|
|
60
|
+
const local = url.protocol === 'http:' && (LOCAL_SINK_HOSTS.has(url.hostname) || !url.hostname.includes('.'));
|
|
61
|
+
if (url.protocol !== 'https:' && !local) {
|
|
62
|
+
throw new MailerConfigError(`mailer: ${source} must be an https URL (http is allowed only for localhost, 127.0.0.1, [::1] or a single-label internal host).`);
|
|
63
|
+
}
|
|
64
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
65
|
+
throw new MailerConfigError(`mailer: ${source} must not carry credentials, a query string or a fragment.`);
|
|
66
|
+
}
|
|
67
|
+
return raw.replace(/\/+$/, '');
|
|
68
|
+
}
|
|
94
69
|
/**
|
|
95
70
|
* Matches Lettermint's confirmed domain-restriction refusal message
|
|
96
71
|
* (`LettermintDomainRefusedError`'s own doc comment has the exact quote and
|
|
@@ -108,13 +83,14 @@ function defaultLettermintFetch() {
|
|
|
108
83
|
}
|
|
109
84
|
return globalThis.fetch.bind(globalThis);
|
|
110
85
|
}
|
|
111
|
-
function createLettermintClient(apiKey, fetchImpl) {
|
|
86
|
+
function createLettermintClient(apiKey, fetchImpl, baseUrl) {
|
|
112
87
|
return {
|
|
113
88
|
async send(payload) {
|
|
114
|
-
const response = await fetchImpl(
|
|
89
|
+
const response = await fetchImpl(`${baseUrl}/send`, {
|
|
115
90
|
method: 'POST',
|
|
116
91
|
headers: { 'x-lettermint-token': apiKey, 'content-type': 'application/json' },
|
|
117
92
|
body: JSON.stringify(payload),
|
|
93
|
+
signal: AbortSignal.timeout(LETTERMINT_TIMEOUT_MS),
|
|
118
94
|
});
|
|
119
95
|
const body = (await response.json());
|
|
120
96
|
if (!response.ok) {
|
|
@@ -128,8 +104,8 @@ function createLettermintClient(apiKey, fetchImpl) {
|
|
|
128
104
|
};
|
|
129
105
|
}
|
|
130
106
|
/**
|
|
131
|
-
* Sends over Lettermint (EU, GDPR
|
|
132
|
-
*
|
|
107
|
+
* Sends over Lettermint (EU, GDPR; see
|
|
108
|
+
* [0006](../docs/adr/0006-lettermint-transport.md)),
|
|
133
109
|
* via plain `fetch` against Lettermint's Sending API. Base URL, auth header
|
|
134
110
|
* and request/response shapes:
|
|
135
111
|
* https://lettermint.co/docs/api-reference/sending/0.0.1/lettermint-sending-openapi.json
|
|
@@ -146,8 +122,9 @@ export function createLettermintTransport(options) {
|
|
|
146
122
|
if (!options.client && !options.apiKey) {
|
|
147
123
|
throw new MailerConfigError('createLettermintTransport needs either "apiKey" or "client".');
|
|
148
124
|
}
|
|
125
|
+
const baseUrl = options.baseUrl === undefined ? LETTERMINT_BASE_URL : parseBaseUrl(options.baseUrl, 'baseUrl');
|
|
149
126
|
const client = options.client ??
|
|
150
|
-
createLettermintClient(options.apiKey, options.fetch ?? defaultLettermintFetch());
|
|
127
|
+
createLettermintClient(options.apiKey, options.fetch ?? defaultLettermintFetch(), baseUrl);
|
|
151
128
|
return {
|
|
152
129
|
async send(message) {
|
|
153
130
|
const { message_id, status } = await client.send({
|
|
@@ -172,15 +149,27 @@ export function createLettermintTransport(options) {
|
|
|
172
149
|
};
|
|
173
150
|
}
|
|
174
151
|
/**
|
|
175
|
-
* `
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
152
|
+
* When `LETTERMINT_API_URL` is set (in any `NODE_ENV`), returns a Lettermint
|
|
153
|
+
* transport that sends to that base URL with the token in
|
|
154
|
+
* `LETTERMINT_API_TOKEN`: how a stage app, a production build holding real
|
|
155
|
+
* addresses, sends every mail to a sink server. A malformed URL, or a URL
|
|
156
|
+
* with no token, throws `MailerConfigError` here, before any send.
|
|
157
|
+
*
|
|
158
|
+
* Otherwise: `dev` outside `NODE_ENV=production`; in production there is no
|
|
159
|
+
* default, and `send()` must be given an explicit transport.
|
|
180
160
|
*/
|
|
181
161
|
export function defaultTransport() {
|
|
162
|
+
const sinkUrl = process.env.LETTERMINT_API_URL;
|
|
163
|
+
if (sinkUrl) {
|
|
164
|
+
const baseUrl = parseBaseUrl(sinkUrl, 'LETTERMINT_API_URL');
|
|
165
|
+
const apiKey = process.env.LETTERMINT_API_TOKEN;
|
|
166
|
+
if (!apiKey) {
|
|
167
|
+
throw new MailerConfigError('mailer: LETTERMINT_API_URL is set but LETTERMINT_API_TOKEN is not.');
|
|
168
|
+
}
|
|
169
|
+
return createLettermintTransport({ apiKey, baseUrl });
|
|
170
|
+
}
|
|
182
171
|
if (process.env.NODE_ENV === 'production') {
|
|
183
|
-
throw new MailerConfigError('send() needs an explicit transport in production: pass { transport:
|
|
172
|
+
throw new MailerConfigError('send() needs an explicit transport in production: pass { transport: createLettermintTransport({ apiKey }) }.');
|
|
184
173
|
}
|
|
185
174
|
return createDevTransport();
|
|
186
175
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -42,10 +42,8 @@ export interface SenderIdentity {
|
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* One file attached to a send. `content` is base64-encoded bytes -- the
|
|
45
|
-
* convention
|
|
46
|
-
*
|
|
47
|
-
* attached file, passed as a buffer or Base64 string"; Lettermint's Sending
|
|
48
|
-
* API OpenAPI spec, `attachments[].content`, and its official SDKs, which
|
|
45
|
+
* convention the Lettermint Sending API uses (its
|
|
46
|
+
* OpenAPI spec, `attachments[].content`, and its official SDKs, which
|
|
49
47
|
* all base64-encode before setting it).
|
|
50
48
|
*/
|
|
51
49
|
export interface Attachment {
|
|
@@ -62,11 +60,11 @@ export interface MailMessage {
|
|
|
62
60
|
/** What a transport returns once it has accepted a message. */
|
|
63
61
|
export interface SendResult {
|
|
64
62
|
readonly id: string;
|
|
65
|
-
readonly transport: '
|
|
63
|
+
readonly transport: 'dev' | 'lettermint';
|
|
66
64
|
}
|
|
67
65
|
/**
|
|
68
66
|
* `send()` has no usable default (no transport given, and either
|
|
69
|
-
* production with no explicit transport, or a
|
|
67
|
+
* production with no explicit transport, or a transport built with
|
|
70
68
|
* neither `apiKey` nor `client`). Thrown rather than a silent no-op or an
|
|
71
69
|
* accidental network call.
|
|
72
70
|
*/
|
package/dist/types.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export const DEFAULT_LOCALE = 'en';
|
|
3
3
|
/**
|
|
4
4
|
* `send()` has no usable default (no transport given, and either
|
|
5
|
-
* production with no explicit transport, or a
|
|
5
|
+
* production with no explicit transport, or a transport built with
|
|
6
6
|
* neither `apiKey` nor `client`). Thrown rather than a silent no-op or an
|
|
7
7
|
* accidental network call.
|
|
8
8
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/mailer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "One send(template, to, data) call for the estate's transactional email, over
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "One send(template, to, data) call for the estate's transactional email, over Lettermint, with a dev transport that writes to disk instead of sending.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/wtfalch/mailer",
|
|
@@ -33,17 +33,8 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@wtfalch/utils": "0.1.0"
|
|
35
35
|
},
|
|
36
|
-
"peerDependencies": {
|
|
37
|
-
"resend": "^4.1.0"
|
|
38
|
-
},
|
|
39
|
-
"peerDependenciesMeta": {
|
|
40
|
-
"resend": {
|
|
41
|
-
"optional": true
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
36
|
"devDependencies": {
|
|
45
37
|
"@types/node": "^22",
|
|
46
|
-
"resend": "^4.1.0",
|
|
47
38
|
"typescript": "^5.9.0",
|
|
48
39
|
"vitest": "^4.1.6"
|
|
49
40
|
}
|