@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 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
@@ -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
- * `dev`, unconditionally, outside `NODE_ENV=production`. In production
126
- * there is no default: `send()` must be given an explicit transport,
127
- * because mailer never holds a mail API key of its own to fall back to.
128
- * Calling `send()` in production with no transport is a configuration bug,
129
- * not a silent no-op and not an accidental live send.
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 LETTERMINT_SEND_URL = 'https://api.lettermint.co/v1/send';
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(LETTERMINT_SEND_URL, {
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
- * `dev`, unconditionally, outside `NODE_ENV=production`. In production
125
- * there is no default: `send()` must be given an explicit transport,
126
- * because mailer never holds a mail API key of its own to fall back to.
127
- * Calling `send()` in production with no transport is a configuration bug,
128
- * not a silent no-op and not an accidental live send.
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.4.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",