@wtfalch/mailer 0.5.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,24 +36,35 @@ 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
- Two env names change that default, in every `NODE_ENV`: with
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
49
59
  `LETTERMINT_API_URL` set (a base URL such as `https://sink.example.com/v1`;
50
60
  https, or http for `localhost`, `127.0.0.1` or `[::1]` only) `send()` sends
51
61
  through Lettermint's API shape to that server, with the token from
52
62
  `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 })`.
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 })`.
57
68
 
58
69
  A held key: however you resolve `apiKey` for `createLettermintTransport`,
59
70
  mailer never fetches or stores it. In the estate, the caller resolves the
@@ -75,9 +86,6 @@ never fetches or stores it. In the estate, the caller resolves the org's
75
86
  Lettermint key as a held key from `@wtfalch/keys` and passes it
76
87
  in on every call.
77
88
 
78
- - `createDevTransport({ dir })` -- writes each message to
79
- `<dir>/<timestamp>-<id>.json` (default `dir`: `.mailer/dev`). Never
80
- opens a network connection.
81
89
  - `createLettermintTransport({ apiKey })` -- sends over Lettermint's
82
90
  Sending API (EU-hosted). Needs either `apiKey` (the org's Lettermint
83
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
@@ -128,14 +118,38 @@ export declare class LettermintDomainRefusedError extends Error {
128
118
  * the network.
129
119
  */
130
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;
131
137
  /**
132
- * When `LETTERMINT_API_URL` is set (in any `NODE_ENV`), returns a Lettermint
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
133
144
  * transport that sends to that base URL with the token in
134
145
  * `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.
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.
137
149
  *
138
- * Otherwise: `dev` outside `NODE_ENV=production`; in production there is no
139
- * default, and `send()` must be given an explicit transport.
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.
140
154
  */
141
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
@@ -148,17 +128,78 @@ export function createLettermintTransport(options) {
148
128
  },
149
129
  };
150
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
+ }
151
138
  /**
152
- * When `LETTERMINT_API_URL` is set (in any `NODE_ENV`), returns a Lettermint
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
+ }
181
+ /**
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
153
188
  * transport that sends to that base URL with the token in
154
189
  * `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.
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.
157
193
  *
158
- * Otherwise: `dev` outside `NODE_ENV=production`; in production there is no
159
- * default, and `send()` must be given an explicit transport.
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.
160
198
  */
161
199
  export function defaultTransport() {
200
+ const mailSinkUrl = process.env.MAIL_SINK_URL;
201
+ if (mailSinkUrl)
202
+ return createMailpitTransport({ baseUrl: mailSinkUrl });
162
203
  const sinkUrl = process.env.LETTERMINT_API_URL;
163
204
  if (sinkUrl) {
164
205
  const baseUrl = parseBaseUrl(sinkUrl, 'LETTERMINT_API_URL');
@@ -168,8 +209,5 @@ export function defaultTransport() {
168
209
  }
169
210
  return createLettermintTransport({ apiKey, baseUrl });
170
211
  }
171
- if (process.env.NODE_ENV === 'production') {
172
- throw new MailerConfigError('send() needs an explicit transport in production: pass { transport: createLettermintTransport({ apiKey }) }.');
173
- }
174
- 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.');
175
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.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.",
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",