@wtfalch/mailer 0.4.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 +10 -0
- package/dist/transport.d.ts +15 -5
- package/dist/transport.js +49 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,6 +45,16 @@ 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
|
+
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
|
+
|
|
48
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
60
|
org's Lettermint key as a held key from `@wtfalch/keys` and passes it in on
|
package/dist/transport.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export type LettermintFetchLike = (url: string, init: {
|
|
|
31
31
|
method: string;
|
|
32
32
|
headers: Record<string, string>;
|
|
33
33
|
body: string;
|
|
34
|
+
signal?: AbortSignal;
|
|
34
35
|
}) => Promise<{
|
|
35
36
|
ok: boolean;
|
|
36
37
|
status: number;
|
|
@@ -87,6 +88,12 @@ export interface LettermintTransportOptions {
|
|
|
87
88
|
/** Injectable `fetch`, used only when building the default client from
|
|
88
89
|
* `apiKey`. Defaults to `globalThis.fetch`. */
|
|
89
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;
|
|
90
97
|
}
|
|
91
98
|
/**
|
|
92
99
|
* Thrown when Lettermint refuses to send because the `from` domain is not
|
|
@@ -122,10 +129,13 @@ export declare class LettermintDomainRefusedError extends Error {
|
|
|
122
129
|
*/
|
|
123
130
|
export declare function createLettermintTransport(options: LettermintTransportOptions): Transport;
|
|
124
131
|
/**
|
|
125
|
-
* `
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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.
|
|
130
140
|
*/
|
|
131
141
|
export declare function defaultTransport(): Transport;
|
package/dist/transport.js
CHANGED
|
@@ -39,7 +39,33 @@ export class LettermintDomainRefusedError extends Error {
|
|
|
39
39
|
this.name = 'LettermintDomainRefusedError';
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
-
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
|
+
}
|
|
43
69
|
/**
|
|
44
70
|
* Matches Lettermint's confirmed domain-restriction refusal message
|
|
45
71
|
* (`LettermintDomainRefusedError`'s own doc comment has the exact quote and
|
|
@@ -57,13 +83,14 @@ function defaultLettermintFetch() {
|
|
|
57
83
|
}
|
|
58
84
|
return globalThis.fetch.bind(globalThis);
|
|
59
85
|
}
|
|
60
|
-
function createLettermintClient(apiKey, fetchImpl) {
|
|
86
|
+
function createLettermintClient(apiKey, fetchImpl, baseUrl) {
|
|
61
87
|
return {
|
|
62
88
|
async send(payload) {
|
|
63
|
-
const response = await fetchImpl(
|
|
89
|
+
const response = await fetchImpl(`${baseUrl}/send`, {
|
|
64
90
|
method: 'POST',
|
|
65
91
|
headers: { 'x-lettermint-token': apiKey, 'content-type': 'application/json' },
|
|
66
92
|
body: JSON.stringify(payload),
|
|
93
|
+
signal: AbortSignal.timeout(LETTERMINT_TIMEOUT_MS),
|
|
67
94
|
});
|
|
68
95
|
const body = (await response.json());
|
|
69
96
|
if (!response.ok) {
|
|
@@ -95,8 +122,9 @@ export function createLettermintTransport(options) {
|
|
|
95
122
|
if (!options.client && !options.apiKey) {
|
|
96
123
|
throw new MailerConfigError('createLettermintTransport needs either "apiKey" or "client".');
|
|
97
124
|
}
|
|
125
|
+
const baseUrl = options.baseUrl === undefined ? LETTERMINT_BASE_URL : parseBaseUrl(options.baseUrl, 'baseUrl');
|
|
98
126
|
const client = options.client ??
|
|
99
|
-
createLettermintClient(options.apiKey, options.fetch ?? defaultLettermintFetch());
|
|
127
|
+
createLettermintClient(options.apiKey, options.fetch ?? defaultLettermintFetch(), baseUrl);
|
|
100
128
|
return {
|
|
101
129
|
async send(message) {
|
|
102
130
|
const { message_id, status } = await client.send({
|
|
@@ -121,13 +149,25 @@ export function createLettermintTransport(options) {
|
|
|
121
149
|
};
|
|
122
150
|
}
|
|
123
151
|
/**
|
|
124
|
-
* `
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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.
|
|
129
160
|
*/
|
|
130
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
|
+
}
|
|
131
171
|
if (process.env.NODE_ENV === 'production') {
|
|
132
172
|
throw new MailerConfigError('send() needs an explicit transport in production: pass { transport: createLettermintTransport({ apiKey }) }.');
|
|
133
173
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/mailer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
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",
|