@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 +21 -13
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/send.d.ts +6 -6
- package/dist/send.js +4 -4
- package/dist/transport.d.ts +29 -15
- package/dist/transport.js +67 -29
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
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
|
|
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 {
|
|
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:
|
|
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()`
|
|
44
|
-
`
|
|
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
|
-
|
|
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,
|
|
54
|
-
sink. A malformed URL, or a URL with no token, throws
|
|
55
|
-
when the default transport is built, before any send.
|
|
56
|
-
available per transport as
|
|
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 {
|
|
4
|
-
export {
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
|
18
|
-
* `MailerConfigError`
|
|
19
|
-
* instead of a silent no-op or an accidental live send
|
|
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
|
|
14
|
-
* `MailerConfigError`
|
|
15
|
-
* instead of a silent no-op or an accidental live send
|
|
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];
|
package/dist/transport.d.ts
CHANGED
|
@@ -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 `
|
|
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
|
|
136
|
-
* with no token, throws `MailerConfigError` here,
|
|
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
|
|
139
|
-
*
|
|
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
|
-
*
|
|
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
|
|
156
|
-
* with no token, throws `MailerConfigError` here,
|
|
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
|
|
159
|
-
*
|
|
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
|
-
|
|
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: '
|
|
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
|
-
"description": "One send(template, to, data) call for the estate's transactional email, over Lettermint
|
|
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",
|