@wtfalch/mailer 0.4.0 → 0.6.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
@@ -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 Lettermint, with a dev transport that writes to disk instead of sending.
4
+ over Lettermint.
5
5
 
6
6
  ## Install
7
7
 
@@ -15,7 +15,7 @@ Call `send()` with a template name, the recipient, that template's data,
15
15
  and who the message is from:
16
16
 
17
17
  ```ts
18
- import { createDevTransport, send } from '@wtfalch/mailer';
18
+ import { createLettermintTransport, send } from '@wtfalch/mailer';
19
19
 
20
20
  await send({
21
21
  template: 'invitation',
@@ -36,15 +36,36 @@ await send({
36
36
  attachments: [ // optional
37
37
  { filename: 'invoice.pdf', contentType: 'application/pdf', content: 'JVBERi0xLjQK...' }, // content is base64
38
38
  ],
39
- transport: createDevTransport(), // writes to .mailer/dev instead of sending
39
+ transport: createLettermintTransport({ apiKey }), // resolved by the caller, e.g. from @wtfalch/keys
40
40
  });
41
41
  ```
42
42
 
43
- `transport` is optional. Left out, `send()` uses the dev transport outside
44
- `NODE_ENV=production` and throws `MailerConfigError` in production -- a
43
+ `transport` is optional. Left out, `send()` calls `defaultTransport()`,
44
+ which throws `MailerConfigError` unless `MAIL_SINK_URL` or `LETTERMINT_API_URL` is set -- a
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
+ `MAIL_SINK_URL` (a Mailpit base URL such as `http://acme-stage-sink:8025`;
49
+ https, or http for `localhost`, `127.0.0.1`, `[::1]` or a single-label
50
+ internal host) makes the default transport post every message to
51
+ `<MAIL_SINK_URL>/api/v1/send` in Mailpit's send-API JSON, with no token
52
+ header. It wins over every setting below, `LETTERMINT_API_TOKEN` included,
53
+ so a stage app never reaches the real provider. A malformed URL throws
54
+ `MailerConfigError` when the default transport is built, before any send.
55
+ The same transport is `createMailpitTransport({ baseUrl })`. A non-2xx answer
56
+ throws a fixed message with the status, never the response body or the mail.
57
+
58
+ Otherwise two env names set that default, in every `NODE_ENV`: with
59
+ `LETTERMINT_API_URL` set (a base URL such as `https://sink.example.com/v1`;
60
+ https, or http for `localhost`, `127.0.0.1` or `[::1]` only) `send()` sends
61
+ through Lettermint's API shape to that server, with the token from
62
+ `LETTERMINT_API_TOKEN` in the `x-lettermint-token` header. This is how a
63
+ stage app, a production build holding real addresses, or the local stack
64
+ sends every mail to a sink. A malformed URL, or a URL with no token, throws
65
+ `MailerConfigError` when the default transport is built, before any send.
66
+ The same URL is available per transport as
67
+ `createLettermintTransport({ apiKey, baseUrl })`.
68
+
48
69
  A held key: however you resolve `apiKey` for `createLettermintTransport`,
49
70
  mailer never fetches or stores it. In the estate, the caller resolves the
50
71
  org's Lettermint key as a held key from `@wtfalch/keys` and passes it in on
@@ -65,9 +86,6 @@ never fetches or stores it. In the estate, the caller resolves the org's
65
86
  Lettermint key as a held key from `@wtfalch/keys` and passes it
66
87
  in on every call.
67
88
 
68
- - `createDevTransport({ dir })` -- writes each message to
69
- `<dir>/<timestamp>-<id>.json` (default `dir`: `.mailer/dev`). Never
70
- opens a network connection.
71
89
  - `createLettermintTransport({ apiKey })` -- sends over Lettermint's
72
90
  Sending API (EU-hosted). Needs either `apiKey` (the org's Lettermint
73
91
  Project API token, `lm_...` -- resolve it yourself, e.g. from
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, Transport, } from './transport.js';
4
- export { createDevTransport, createLettermintTransport, defaultTransport, LettermintDomainRefusedError, } from './transport.js';
3
+ export type { LettermintAttachment, LettermintFetchLike, LettermintLike, LettermintSendPayload, LettermintTransportOptions, MailpitTransportOptions, OutgoingMail, Transport, } from './transport.js';
4
+ export { createLettermintTransport, createMailpitTransport, 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, defaultTransport, LettermintDomainRefusedError, } from './transport.js';
2
+ export { createLettermintTransport, createMailpitTransport, 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/send.d.ts CHANGED
@@ -6,17 +6,17 @@ export interface SendOptions<T extends TemplateName> {
6
6
  readonly to: string;
7
7
  readonly data: TemplateData<T>;
8
8
  readonly from: SenderIdentity;
9
- /** Defaults to the dev transport outside production; see `defaultTransport`. */
9
+ /** Defaults to `defaultTransport()`, which needs `LETTERMINT_API_URL` or throws. */
10
10
  readonly transport?: Transport;
11
- /** Files to attach, e.g. an invoice or credit note document. Both shipped transports accept these. */
11
+ /** Files to attach, e.g. an invoice or credit note document. The shipped transport accepts these. */
12
12
  readonly attachments?: readonly Attachment[];
13
13
  }
14
14
  /**
15
15
  * One template, rendered and handed to a transport.
16
16
  *
17
- * `transport` defaults to the dev transport outside production and throws
18
- * `MailerConfigError` in production, so a missing config fails loudly
19
- * instead of a silent no-op or an accidental live send (see
20
- * `transport.ts`, `defaultTransport`).
17
+ * `transport` defaults to `defaultTransport()`, which throws
18
+ * `MailerConfigError` unless `LETTERMINT_API_URL` is set, so a missing
19
+ * config fails loudly instead of a silent no-op or an accidental live send
20
+ * (see `transport.ts`, `defaultTransport`).
21
21
  */
22
22
  export declare function send<T extends TemplateName>(options: SendOptions<T>): Promise<SendResult>;
package/dist/send.js CHANGED
@@ -10,10 +10,10 @@ function formatFrom(identity) {
10
10
  /**
11
11
  * One template, rendered and handed to a transport.
12
12
  *
13
- * `transport` defaults to the dev transport outside production and throws
14
- * `MailerConfigError` in production, so a missing config fails loudly
15
- * instead of a silent no-op or an accidental live send (see
16
- * `transport.ts`, `defaultTransport`).
13
+ * `transport` defaults to `defaultTransport()`, which throws
14
+ * `MailerConfigError` unless `LETTERMINT_API_URL` is set, so a missing
15
+ * config fails loudly instead of a silent no-op or an accidental live send
16
+ * (see `transport.ts`, `defaultTransport`).
17
17
  */
18
18
  export async function send(options) {
19
19
  const render = templates[options.template];
@@ -10,16 +10,6 @@ export interface OutgoingMail extends MailMessage {
10
10
  export interface Transport {
11
11
  send(message: OutgoingMail): Promise<SendResult>;
12
12
  }
13
- export interface DevTransportOptions {
14
- /** Directory messages are written to. Default: `.mailer/dev`. */
15
- readonly dir?: string;
16
- }
17
- /**
18
- * Writes every message to `<dir>/<timestamp>-<id>.json` instead of sending
19
- * it. Never opens a network connection, so nothing that sends mail needs
20
- * a live key to be tested.
21
- */
22
- export declare function createDevTransport(options?: DevTransportOptions): Transport;
23
13
  /**
24
14
  * The minimal shape this package calls on `fetch` to speak Lettermint's
25
15
  * Sending API directly -- the same no-SDK, injectable-`fetch` posture
@@ -31,6 +21,7 @@ export type LettermintFetchLike = (url: string, init: {
31
21
  method: string;
32
22
  headers: Record<string, string>;
33
23
  body: string;
24
+ signal?: AbortSignal;
34
25
  }) => Promise<{
35
26
  ok: boolean;
36
27
  status: number;
@@ -87,6 +78,12 @@ export interface LettermintTransportOptions {
87
78
  /** Injectable `fetch`, used only when building the default client from
88
79
  * `apiKey`. Defaults to `globalThis.fetch`. */
89
80
  readonly fetch?: LettermintFetchLike;
81
+ /** The Sending API base URL, without `/send`. Default
82
+ * `https://api.lettermint.co/v1`. Must be https, except for a local sink
83
+ * host (`localhost`, `127.0.0.1`, `[::1]`), so a misconfigured value
84
+ * cannot send mail or the token over plain http. Ignored when `client`
85
+ * is given. */
86
+ readonly baseUrl?: string;
90
87
  }
91
88
  /**
92
89
  * Thrown when Lettermint refuses to send because the `from` domain is not
@@ -121,11 +118,38 @@ export declare class LettermintDomainRefusedError extends Error {
121
118
  * the network.
122
119
  */
123
120
  export declare function createLettermintTransport(options: LettermintTransportOptions): Transport;
121
+ export interface MailpitTransportOptions {
122
+ /** The Mailpit base URL without `/api/v1/send`, e.g. `http://acme-stage-sink:8025`.
123
+ * Same rule as the Lettermint base URL: https, or http for a local or
124
+ * single-label internal host. A malformed value throws `MailerConfigError`. */
125
+ readonly baseUrl: string;
126
+ /** Injectable `fetch`. Defaults to `globalThis.fetch`. */
127
+ readonly fetch?: LettermintFetchLike;
128
+ }
129
+ /**
130
+ * Sends to a Mailpit server's send API (`POST <baseUrl>/api/v1/send`,
131
+ * https://mailpit.axllent.org/docs/api-v1/view.html#post-/api/v1/send), the
132
+ * stage mail sink. Sends no token header: a stage app holds no mail
133
+ * provider token. A non-2xx answer throws a fixed message with the status
134
+ * only, never the response body or the message content.
135
+ */
136
+ export declare function createMailpitTransport(options: MailpitTransportOptions): Transport;
124
137
  /**
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.
138
+ * When `MAIL_SINK_URL` is set, returns a Mailpit transport to
139
+ * that URL. It wins over every other setting, `LETTERMINT_API_TOKEN` included,
140
+ * so a stage app never reaches the real provider. A malformed URL throws
141
+ * `MailerConfigError` here, before any send.
142
+ *
143
+ * Otherwise, when `LETTERMINT_API_URL` is set (in any `NODE_ENV`), returns a Lettermint
144
+ * transport that sends to that base URL with the token in
145
+ * `LETTERMINT_API_TOKEN`: how a stage app, a production build holding real
146
+ * addresses, or the local stack sends every mail to a sink server. A
147
+ * malformed URL, or a URL with no token, throws `MailerConfigError` here,
148
+ * before any send.
149
+ *
150
+ * Otherwise throws `MailerConfigError`: this package ships no stand-in
151
+ * transport (docs/adr/0008-remove-dev-transport.md), so `send()` must be
152
+ * given an explicit transport, or the caller must point `LETTERMINT_API_URL`
153
+ * at a sink.
130
154
  */
131
155
  export declare function defaultTransport(): Transport;
package/dist/transport.js CHANGED
@@ -1,24 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { mkdirSync, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
1
  import { MailerConfigError } from './types.js';
5
- /**
6
- * Writes every message to `<dir>/<timestamp>-<id>.json` instead of sending
7
- * it. Never opens a network connection, so nothing that sends mail needs
8
- * a live key to be tested.
9
- */
10
- export function createDevTransport(options = {}) {
11
- const dir = options.dir ?? '.mailer/dev';
12
- return {
13
- async send(message) {
14
- mkdirSync(dir, { recursive: true });
15
- const id = randomUUID();
16
- const path = join(dir, `${Date.now()}-${id}.json`);
17
- writeFileSync(path, JSON.stringify(message, null, 2));
18
- return { id, transport: 'dev' };
19
- },
20
- };
21
- }
22
2
  /**
23
3
  * Thrown when Lettermint refuses to send because the `from` domain is not
24
4
  * one the sending project may use -- either unverified, or verified but
@@ -39,7 +19,33 @@ export class LettermintDomainRefusedError extends Error {
39
19
  this.name = 'LettermintDomainRefusedError';
40
20
  }
41
21
  }
42
- const LETTERMINT_SEND_URL = 'https://api.lettermint.co/v1/send';
22
+ const LETTERMINT_BASE_URL = 'https://api.lettermint.co/v1';
23
+ const LETTERMINT_TIMEOUT_MS = 30_000;
24
+ const LOCAL_SINK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
25
+ /**
26
+ * Validates a Sending API base URL where it is read and returns it without a
27
+ * trailing slash. Rule: https, or http only for a local sink host. Anything
28
+ * else throws `MailerConfigError`, so a bad value fails at start, not at the
29
+ * first send.
30
+ */
31
+ function parseBaseUrl(raw, source) {
32
+ let url;
33
+ try {
34
+ url = new URL(raw);
35
+ }
36
+ catch {
37
+ throw new MailerConfigError(`mailer: ${source} is not a valid URL.`);
38
+ }
39
+ // A single-label host (a container name such as `acme-stage-sink`) only resolves on the internal network.
40
+ const local = url.protocol === 'http:' && (LOCAL_SINK_HOSTS.has(url.hostname) || !url.hostname.includes('.'));
41
+ if (url.protocol !== 'https:' && !local) {
42
+ 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).`);
43
+ }
44
+ if (url.username || url.password || url.search || url.hash) {
45
+ throw new MailerConfigError(`mailer: ${source} must not carry credentials, a query string or a fragment.`);
46
+ }
47
+ return raw.replace(/\/+$/, '');
48
+ }
43
49
  /**
44
50
  * Matches Lettermint's confirmed domain-restriction refusal message
45
51
  * (`LettermintDomainRefusedError`'s own doc comment has the exact quote and
@@ -57,13 +63,14 @@ function defaultLettermintFetch() {
57
63
  }
58
64
  return globalThis.fetch.bind(globalThis);
59
65
  }
60
- function createLettermintClient(apiKey, fetchImpl) {
66
+ function createLettermintClient(apiKey, fetchImpl, baseUrl) {
61
67
  return {
62
68
  async send(payload) {
63
- const response = await fetchImpl(LETTERMINT_SEND_URL, {
69
+ const response = await fetchImpl(`${baseUrl}/send`, {
64
70
  method: 'POST',
65
71
  headers: { 'x-lettermint-token': apiKey, 'content-type': 'application/json' },
66
72
  body: JSON.stringify(payload),
73
+ signal: AbortSignal.timeout(LETTERMINT_TIMEOUT_MS),
67
74
  });
68
75
  const body = (await response.json());
69
76
  if (!response.ok) {
@@ -95,8 +102,9 @@ export function createLettermintTransport(options) {
95
102
  if (!options.client && !options.apiKey) {
96
103
  throw new MailerConfigError('createLettermintTransport needs either "apiKey" or "client".');
97
104
  }
105
+ const baseUrl = options.baseUrl === undefined ? LETTERMINT_BASE_URL : parseBaseUrl(options.baseUrl, 'baseUrl');
98
106
  const client = options.client ??
99
- createLettermintClient(options.apiKey, options.fetch ?? defaultLettermintFetch());
107
+ createLettermintClient(options.apiKey, options.fetch ?? defaultLettermintFetch(), baseUrl);
100
108
  return {
101
109
  async send(message) {
102
110
  const { message_id, status } = await client.send({
@@ -120,16 +128,86 @@ export function createLettermintTransport(options) {
120
128
  },
121
129
  };
122
130
  }
131
+ /** Splits `Name <addr>` or a bare address into Mailpit's `{ Email, Name }`. */
132
+ function toMailpitAddress(formatted) {
133
+ const match = /^(.*)<([^<>]+)>\s*$/.exec(formatted);
134
+ if (!match)
135
+ return { Email: formatted.trim(), Name: '' };
136
+ return { Email: match[2].trim(), Name: match[1].trim() };
137
+ }
138
+ /**
139
+ * Sends to a Mailpit server's send API (`POST <baseUrl>/api/v1/send`,
140
+ * https://mailpit.axllent.org/docs/api-v1/view.html#post-/api/v1/send), the
141
+ * stage mail sink. Sends no token header: a stage app holds no mail
142
+ * provider token. A non-2xx answer throws a fixed message with the status
143
+ * only, never the response body or the message content.
144
+ */
145
+ export function createMailpitTransport(options) {
146
+ const baseUrl = parseBaseUrl(options.baseUrl, 'baseUrl');
147
+ const fetchImpl = options.fetch ?? defaultLettermintFetch();
148
+ return {
149
+ async send(message) {
150
+ const payload = {
151
+ From: toMailpitAddress(message.from),
152
+ To: [toMailpitAddress(message.to)],
153
+ Subject: message.subject,
154
+ Text: message.text,
155
+ HTML: message.html,
156
+ ...(message.replyTo ? { ReplyTo: [toMailpitAddress(message.replyTo)] } : {}),
157
+ ...(message.attachments
158
+ ? {
159
+ Attachments: message.attachments.map((attachment) => ({
160
+ Filename: attachment.filename,
161
+ Content: attachment.content,
162
+ ...(attachment.contentType ? { ContentType: attachment.contentType } : {}),
163
+ })),
164
+ }
165
+ : {}),
166
+ };
167
+ const response = await fetchImpl(`${baseUrl}/api/v1/send`, {
168
+ method: 'POST',
169
+ headers: { 'content-type': 'application/json' },
170
+ body: JSON.stringify(payload),
171
+ signal: AbortSignal.timeout(LETTERMINT_TIMEOUT_MS),
172
+ });
173
+ if (!response.ok) {
174
+ throw new Error(`mailer: the mail sink refused the message (status ${response.status}).`);
175
+ }
176
+ const body = (await response.json());
177
+ return { id: body.ID ?? 'unknown', transport: 'mailpit' };
178
+ },
179
+ };
180
+ }
123
181
  /**
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.
182
+ * When `MAIL_SINK_URL` is set, returns a Mailpit transport to
183
+ * that URL. It wins over every other setting, `LETTERMINT_API_TOKEN` included,
184
+ * so a stage app never reaches the real provider. A malformed URL throws
185
+ * `MailerConfigError` here, before any send.
186
+ *
187
+ * Otherwise, when `LETTERMINT_API_URL` is set (in any `NODE_ENV`), returns a Lettermint
188
+ * transport that sends to that base URL with the token in
189
+ * `LETTERMINT_API_TOKEN`: how a stage app, a production build holding real
190
+ * addresses, or the local stack sends every mail to a sink server. A
191
+ * malformed URL, or a URL with no token, throws `MailerConfigError` here,
192
+ * before any send.
193
+ *
194
+ * Otherwise throws `MailerConfigError`: this package ships no stand-in
195
+ * transport (docs/adr/0008-remove-dev-transport.md), so `send()` must be
196
+ * given an explicit transport, or the caller must point `LETTERMINT_API_URL`
197
+ * at a sink.
129
198
  */
130
199
  export function defaultTransport() {
131
- if (process.env.NODE_ENV === 'production') {
132
- throw new MailerConfigError('send() needs an explicit transport in production: pass { transport: createLettermintTransport({ apiKey }) }.');
200
+ const mailSinkUrl = process.env.MAIL_SINK_URL;
201
+ if (mailSinkUrl)
202
+ return createMailpitTransport({ baseUrl: mailSinkUrl });
203
+ const sinkUrl = process.env.LETTERMINT_API_URL;
204
+ if (sinkUrl) {
205
+ const baseUrl = parseBaseUrl(sinkUrl, 'LETTERMINT_API_URL');
206
+ const apiKey = process.env.LETTERMINT_API_TOKEN;
207
+ if (!apiKey) {
208
+ throw new MailerConfigError('mailer: LETTERMINT_API_URL is set but LETTERMINT_API_TOKEN is not.');
209
+ }
210
+ return createLettermintTransport({ apiKey, baseUrl });
133
211
  }
134
- return createDevTransport();
212
+ throw new MailerConfigError('send() needs an explicit transport: pass { transport: createLettermintTransport({ apiKey }) }, or set MAIL_SINK_URL (or LETTERMINT_API_URL) to reach a sink.');
135
213
  }
package/dist/types.d.ts CHANGED
@@ -60,7 +60,7 @@ export interface MailMessage {
60
60
  /** What a transport returns once it has accepted a message. */
61
61
  export interface SendResult {
62
62
  readonly id: string;
63
- readonly transport: 'dev' | 'lettermint';
63
+ readonly transport: 'lettermint' | 'mailpit';
64
64
  }
65
65
  /**
66
66
  * `send()` has no usable default (no transport given, and either
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wtfalch/mailer",
3
- "version": "0.4.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.",
3
+ "version": "0.6.0",
4
+ "description": "One send(template, to, data) call for the estate's transactional email, over Lettermint.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/wtfalch/mailer",