@nxgt/mail 0.1.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/LICENSE +21 -0
- package/README.md +341 -0
- package/dist/chunks/index-0f7kdb8k.js +114 -0
- package/dist/chunks/index-0f7kdb8k.js.map +11 -0
- package/dist/chunks/index-vq4e9n8f.js +39 -0
- package/dist/chunks/index-vq4e9n8f.js.map +10 -0
- package/dist/chunks/index-we4n5yfz.js +28 -0
- package/dist/chunks/index-we4n5yfz.js.map +10 -0
- package/dist/conformance/assert.d.ts +12 -0
- package/dist/conformance/assert.d.ts.map +1 -0
- package/dist/conformance/cases/failure.d.ts +4 -0
- package/dist/conformance/cases/failure.d.ts.map +1 -0
- package/dist/conformance/cases/index.d.ts +6 -0
- package/dist/conformance/cases/index.d.ts.map +1 -0
- package/dist/conformance/cases/send.d.ts +4 -0
- package/dist/conformance/cases/send.d.ts.map +1 -0
- package/dist/conformance/describe.d.ts +37 -0
- package/dist/conformance/describe.d.ts.map +1 -0
- package/dist/conformance/index.d.ts +20 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +297 -0
- package/dist/conformance/index.js.map +16 -0
- package/dist/conformance/reference.d.ts +7 -0
- package/dist/conformance/reference.d.ts.map +1 -0
- package/dist/conformance/sample.d.ts +4 -0
- package/dist/conformance/sample.d.ts.map +1 -0
- package/dist/conformance/types.d.ts +74 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/errors.d.ts +68 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +9 -0
- package/dist/locale.d.ts +33 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/memory.d.ts +34 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/message.d.ts +23 -0
- package/dist/message.d.ts.map +1 -0
- package/dist/renderer.d.ts +80 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +169 -0
- package/dist/renderer.js.map +10 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.d.ts.map +1 -0
- package/docs/README.md +16 -0
- package/docs/guide/locales.md +141 -0
- package/docs/guide/rendering.md +523 -0
- package/docs/guide/sending.md +325 -0
- package/docs/guide/testing.md +175 -0
- package/docs/guide/transports.md +451 -0
- package/docs/roadmap.md +112 -0
- package/docs/troubleshooting.md +1330 -0
- package/package.json +68 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
# Writing a transport
|
|
2
|
+
|
|
3
|
+
This page is for implementing the `Mailer` port on a provider of your choice —
|
|
4
|
+
an HTTP API, an SMTP relay, a queue — and proving it keeps the contract with
|
|
5
|
+
`@nxgt/mail/conformance`. If you only use an existing transport, you do not need
|
|
6
|
+
it.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { describe, it } from 'bun:test';
|
|
10
|
+
import { describeMailer } from '@nxgt/mail/conformance';
|
|
11
|
+
import { createHttpMailer } from './http-mailer'; // yours, below
|
|
12
|
+
import { fakeProvider } from './fake-provider'; // yours, below
|
|
13
|
+
|
|
14
|
+
describeMailer({
|
|
15
|
+
name: 'the HTTP mailer',
|
|
16
|
+
runner: { describe, it },
|
|
17
|
+
harness: {
|
|
18
|
+
async open() {
|
|
19
|
+
const provider = fakeProvider(); // one per case, never shared
|
|
20
|
+
return {
|
|
21
|
+
mailer: createHttpMailer({ endpoint: 'https://mail.example.test/send', apiKey: 'test', fetch: provider.fetch }),
|
|
22
|
+
delivered: async () => provider.delivered(),
|
|
23
|
+
faults: provider.faults,
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## The contract
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
interface Mailer {
|
|
34
|
+
send(message: MailMessage): Promise<SentMail>; // { messageId: string | null }
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A transport:
|
|
39
|
+
|
|
40
|
+
1. **calls `checkMessage(message)` first**, so every transport refuses the same
|
|
41
|
+
things with the same messages;
|
|
42
|
+
2. resolves **only after the hand-over**, with the provider's id, or `null`
|
|
43
|
+
when it gives none;
|
|
44
|
+
3. **throws `MailFailure`** when it could not hand the e-mail over — a refused
|
|
45
|
+
connection, a timeout, a 5xx, an expired credential — with the provider's
|
|
46
|
+
error as `cause`;
|
|
47
|
+
4. **throws `MailRefused`** when the provider refused the message as malformed,
|
|
48
|
+
with its error as `cause`;
|
|
49
|
+
5. **quotes or encodes a recipient's name** as its provider needs it — a
|
|
50
|
+
separate field when the API has one, a quoted or encoded display name in a
|
|
51
|
+
header otherwise. A name is free text: `Ada <mallory@example.test>, "Eve"`
|
|
52
|
+
is a name, and it must reach only its own address;
|
|
53
|
+
6. **never retries in secret**, never resolves `false`, never logs and
|
|
54
|
+
resolves;
|
|
55
|
+
7. **defines no error class of its own**. It throws the classes imported from
|
|
56
|
+
`@nxgt/mail`, declared as a required peer, so `error instanceof MailFailure`
|
|
57
|
+
holds in the application whichever transport threw it. `MailError` is
|
|
58
|
+
abstract, so a bare one cannot be thrown:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@nxgt/mail": "^0.1.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
An error's `message` reports a shape, never a value: never an address, a
|
|
69
|
+
subject, a link, an API key or a connection string. What the provider said goes
|
|
70
|
+
on `cause`.
|
|
71
|
+
|
|
72
|
+
A bad option passed to the transport's factory is a wiring mistake: throw a
|
|
73
|
+
bare `TypeError`, at wiring time, not a `MailError` at the first send.
|
|
74
|
+
|
|
75
|
+
## `checkMessage` first
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
function checkMessage(message: MailMessage): void;
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Throws `MailRefused`, naming **where** the problem is and never the value:
|
|
82
|
+
|
|
83
|
+
| Refused | `message` |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| not an object | `send: the message must be an object` |
|
|
86
|
+
| no `to`, or `to: []` | `send: to must hold at least one address` |
|
|
87
|
+
| a recipient that is not a bare address — a display name, whitespace, `,`, `;` or `:` in a string — `undefined` included | `send: to is not an e-mail address`, `send: to[0] is not an e-mail address` |
|
|
88
|
+
| an address object with a bad address | `send: from.address is not an e-mail address` |
|
|
89
|
+
| a line break in a name | `send: from.name must be a string without a line break` |
|
|
90
|
+
| a part that is not a string | `send: text must be a string` |
|
|
91
|
+
| a line break in the subject | `send: subject must not hold a line break` |
|
|
92
|
+
| a header name that is not letters, digits and hyphens | `send: a header name must be letters, digits and hyphens` |
|
|
93
|
+
| a line break in a header value | `send: header X-Ref must be a string without a line break` |
|
|
94
|
+
| a header the transport writes from the message — `To`, `Cc`, `Bcc`, `From`, `Sender`, `Reply-To`, `Return-Path`, `Subject`, `MIME-Version`, `Content-*`, in any case | `send: header Bcc is reserved — addresses, the subject and the MIME structure are never custom headers` |
|
|
95
|
+
|
|
96
|
+
Two helpers turn addresses into what a provider wants:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
function addressOf(address: Address): string; // the bare address of either form
|
|
100
|
+
function recipientsOf(message: MailMessage): string[]; // every recipient's, in order
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## A transport, over HTTP
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// http-mailer.ts
|
|
107
|
+
import { type Address, checkMessage, MailFailure, type Mailer, MailRefused } from '@nxgt/mail';
|
|
108
|
+
|
|
109
|
+
export interface HttpMailerOptions {
|
|
110
|
+
readonly endpoint: string;
|
|
111
|
+
readonly apiKey: string;
|
|
112
|
+
/** The sender when a message has none. */
|
|
113
|
+
readonly from?: Address;
|
|
114
|
+
/** For tests; the global fetch otherwise. */
|
|
115
|
+
readonly fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createHttpMailer(options: HttpMailerOptions): Mailer {
|
|
119
|
+
// Wiring mistakes: a bare TypeError, now, and never the value.
|
|
120
|
+
if (!/^https?:\/\//.test(options.endpoint)) {
|
|
121
|
+
throw new TypeError('createHttpMailer: endpoint must be an http or https URL');
|
|
122
|
+
}
|
|
123
|
+
if (options.apiKey === '') {
|
|
124
|
+
throw new TypeError('createHttpMailer: apiKey must not be empty');
|
|
125
|
+
}
|
|
126
|
+
const post = options.fetch ?? ((url, init) => fetch(url, init));
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
async send(message) {
|
|
130
|
+
checkMessage(message);
|
|
131
|
+
|
|
132
|
+
let response: Response;
|
|
133
|
+
try {
|
|
134
|
+
response = await post(options.endpoint, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { authorization: `Bearer ${options.apiKey}`, 'content-type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ ...message, from: message.from ?? options.from }),
|
|
138
|
+
});
|
|
139
|
+
} catch (cause) {
|
|
140
|
+
throw new MailFailure('send: the provider could not be reached', { cause });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const cause = new Error(`the provider answered HTTP ${response.status}`);
|
|
145
|
+
if (response.status === 400 || response.status === 422) {
|
|
146
|
+
throw new MailRefused('send: the provider refused the message', { cause });
|
|
147
|
+
}
|
|
148
|
+
throw new MailFailure('send: the provider failed', { cause });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Handed over. A body that is not what we expected is not a failure.
|
|
152
|
+
const body = (await response.json().catch(() => null)) as { id?: unknown } | null;
|
|
153
|
+
return { messageId: typeof body?.id === 'string' && body.id !== '' ? body.id : null };
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## The conformance suite
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
function describeMailer(options: {
|
|
163
|
+
readonly name: string;
|
|
164
|
+
readonly harness: MailerHarness;
|
|
165
|
+
readonly runner?: MailerRunner;
|
|
166
|
+
readonly skip?: Readonly<Record<string, string>>;
|
|
167
|
+
readonly faults?: boolean;
|
|
168
|
+
}): void;
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
| Option | Type | Default | Effect |
|
|
172
|
+
| --- | --- | --- | --- |
|
|
173
|
+
| `name` | `string` | — | Heads the `describe` block: `<name> — @nxgt/mail conformance` |
|
|
174
|
+
| `harness` | `MailerHarness` | — | Opens a fresh transport and receiving end for each case |
|
|
175
|
+
| `runner` | `{ describe, it }` | the global `describe` and `it` | The test framework's functions. **Pass it under `bun test`**, which does not put them on `globalThis` |
|
|
176
|
+
| `skip` | `Record<caseId, reason>` | `{}` | Cases to skip, each with its reason, which appears in the test's name |
|
|
177
|
+
| `faults` | `boolean` | absent | Absent: the failure cases run, and on a harness without faults they **fail** with `conformance: failure.outage: faults not provided: … — pass faults: false to describeMailer to skip it on purpose`. `false` declares that the harness has no faults: the failure cases are skipped, with the reason in their name |
|
|
178
|
+
|
|
179
|
+
`runner` is the smallest part of a test framework the suite needs:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
interface MailerRunner {
|
|
183
|
+
describe(name: string, body: () => void): void;
|
|
184
|
+
it: {
|
|
185
|
+
(name: string, body: () => Promise<void>): void;
|
|
186
|
+
skip(name: string, body: () => Promise<void>): void;
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
bun:test, vitest and jest all have it. A hand-rolled runner needs `it.skip`
|
|
192
|
+
too: every skip — from `skip`, or from `faults: false` — goes through it.
|
|
193
|
+
|
|
194
|
+
It throws a `TypeError` when no runner is found
|
|
195
|
+
(`describeMailer: no test runner found — pass runner: { describe, it } from your test framework`)
|
|
196
|
+
and when `skip` names a case that does not exist
|
|
197
|
+
(`describeMailer: skip names no case: send.nothing`).
|
|
198
|
+
|
|
199
|
+
### The cases
|
|
200
|
+
|
|
201
|
+
| Id | Proves | Needs faults |
|
|
202
|
+
| --- | --- | --- |
|
|
203
|
+
| `send.answersSentMail` | a send answers `SentMail`, with a non-empty string id or `null` | no |
|
|
204
|
+
| `send.deliversBytes` | the subject, the HTML and the text are delivered byte for byte: accents, an emoji, `&` in a link | no |
|
|
205
|
+
| `send.recipients` | every recipient is delivered to, written as a string or with a name | no |
|
|
206
|
+
| `send.hostileName` | a name holding `<…>`, a comma and quotes — `Ada <mallory@example.test>, "Eve" <eve@example.test>;` — reaches only its own address: quoting the name is the transport's job | no |
|
|
207
|
+
| `send.refusesNoRecipient` | no recipient throws `MailRefused`, and nothing is delivered | no |
|
|
208
|
+
| `send.refusesLineBreakInSubject` | a line break in the subject throws `MailRefused`, and nothing is delivered | no |
|
|
209
|
+
| `send.refusesAddressHeader` | a `Bcc` among the custom headers throws `MailRefused` without the address in its message, and nothing is delivered: it would add a recipient no check saw | no |
|
|
210
|
+
| `send.refusesWithoutTheValue` | a refusal's `message` does not hold the refused value | no |
|
|
211
|
+
| `failure.outage` | an outage throws `MailFailure` — **the class from `@nxgt/mail`** — with code `MAIL_FAILED` and a `cause`; one attempt; nothing delivered | yes |
|
|
212
|
+
| `failure.refusal` | a provider's refusal throws `MailRefused` with code `MAIL_REFUSED` and a `cause`; one attempt | yes |
|
|
213
|
+
| `failure.recovers` | after a failure, the next send goes through | yes |
|
|
214
|
+
|
|
215
|
+
The message they send is exported as `sampleMessage`, and the cases as data:
|
|
216
|
+
`sendCases` (the eight `send.*`), `failureCases` (the three `failure.*`) and
|
|
217
|
+
`allMailerCases` (both, in the order above). A transport's own tests can reuse
|
|
218
|
+
them — send the sample through your transport, or run only the cases that
|
|
219
|
+
need no faults:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
import { expect, it } from 'bun:test';
|
|
223
|
+
import { recipientsOf } from '@nxgt/mail';
|
|
224
|
+
import { failureCases, type MailerHarness, runMailerCase, sampleMessage, sendCases } from '@nxgt/mail/conformance';
|
|
225
|
+
|
|
226
|
+
declare const harness: MailerHarness; // yours
|
|
227
|
+
|
|
228
|
+
it('delivers the sample message as sent', async () => {
|
|
229
|
+
const { mailer, delivered, close } = await harness.open();
|
|
230
|
+
await mailer.send(sampleMessage);
|
|
231
|
+
const [mail] = await delivered();
|
|
232
|
+
expect(mail?.to).toEqual(recipientsOf(sampleMessage));
|
|
233
|
+
expect(mail?.subject).toBe(sampleMessage.subject);
|
|
234
|
+
await close?.();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
for (const mailerCase of sendCases) {
|
|
238
|
+
it(mailerCase.id, async () => {
|
|
239
|
+
await runMailerCase(mailerCase, harness);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
failureCases.map((c) => c.needs); // ['faults', 'faults', 'faults']
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## The harness
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
interface MailerHarness {
|
|
250
|
+
open(): Promise<OpenedMailer>;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface OpenedMailer {
|
|
254
|
+
readonly mailer: Mailer;
|
|
255
|
+
delivered(): Promise<readonly DeliveredMail[]>;
|
|
256
|
+
readonly faults?: MailerFaults;
|
|
257
|
+
close?(): Promise<void>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interface DeliveredMail {
|
|
261
|
+
readonly to: readonly string[]; // bare addresses, in order
|
|
262
|
+
readonly subject: string;
|
|
263
|
+
readonly html: string;
|
|
264
|
+
readonly text: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface MailerFaults {
|
|
268
|
+
failNext(kind: 'outage' | 'refusal'): Promise<void>;
|
|
269
|
+
attempts(): Promise<number>;
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
- `open()` is called **once per case** and must answer a fresh transport and a
|
|
274
|
+
fresh receiving end, so no case sees another's messages.
|
|
275
|
+
- `delivered()` reads back what **the receiving end** got — the test SMTP
|
|
276
|
+
server, the recorded request, the fake provider — not what the mailer was
|
|
277
|
+
asked to send.
|
|
278
|
+
- `close()`, when present, is called after the case, pass or fail.
|
|
279
|
+
|
|
280
|
+
### Faults — failing the way the provider fails
|
|
281
|
+
|
|
282
|
+
`faults.failNext('outage')` makes the next hand-over fail as the provider's
|
|
283
|
+
outage does — a refused connection, a 503 — and `failNext('refusal')` as its
|
|
284
|
+
"malformed message" answer does. `attempts()` answers how many hand-overs the
|
|
285
|
+
receiving end saw, failed ones included: it is what proves nothing is retried.
|
|
286
|
+
|
|
287
|
+
Inject the fault **at the provider**, not in a wrapper that throws in front of
|
|
288
|
+
the transport: a wrapper would prove the wrapper, and not the transport's
|
|
289
|
+
translation of its provider's errors.
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
// fake-provider.ts
|
|
293
|
+
import { type MailMessage, recipientsOf } from '@nxgt/mail';
|
|
294
|
+
import type { DeliveredMail, MailerFaults } from '@nxgt/mail/conformance';
|
|
295
|
+
|
|
296
|
+
export function fakeProvider() {
|
|
297
|
+
const inbox: MailMessage[] = [];
|
|
298
|
+
let attempts = 0;
|
|
299
|
+
let next: 'outage' | 'refusal' | null = null;
|
|
300
|
+
|
|
301
|
+
const faults: MailerFaults = {
|
|
302
|
+
async failNext(kind) {
|
|
303
|
+
next = kind;
|
|
304
|
+
},
|
|
305
|
+
async attempts() {
|
|
306
|
+
return attempts;
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
faults,
|
|
312
|
+
async fetch(_url: string, init: RequestInit): Promise<Response> {
|
|
313
|
+
attempts += 1;
|
|
314
|
+
const fault = next;
|
|
315
|
+
next = null;
|
|
316
|
+
if (fault === 'outage') return new Response('unavailable', { status: 503 });
|
|
317
|
+
if (fault === 'refusal') return Response.json({ error: 'malformed' }, { status: 422 });
|
|
318
|
+
inbox.push(JSON.parse(String(init.body)) as MailMessage);
|
|
319
|
+
return Response.json({ id: `fake-${inbox.length}` });
|
|
320
|
+
},
|
|
321
|
+
delivered(): DeliveredMail[] {
|
|
322
|
+
return inbox.map((mail) => ({ to: recipientsOf(mail), subject: mail.subject, html: mail.html, text: mail.text }));
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
With the transport and the fake above, the example at the top of this page
|
|
329
|
+
passes all eleven cases.
|
|
330
|
+
|
|
331
|
+
### Without faults
|
|
332
|
+
|
|
333
|
+
A harness that cannot inject faults leaves `faults` out. The failure cases then
|
|
334
|
+
**fail**, saying why, until you declare it:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
import { describe, it } from 'bun:test';
|
|
338
|
+
import { describeMailer, type MailerHarness } from '@nxgt/mail/conformance';
|
|
339
|
+
|
|
340
|
+
declare const harness: MailerHarness; // yours, with no faults
|
|
341
|
+
|
|
342
|
+
describeMailer({ name: 'my transport', harness, runner: { describe, it }, faults: false });
|
|
343
|
+
// failure.outage: … (skipped: faults not provided: the failure contract is not proven for this transport)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
A skip is always reported with its reason, never passed over. The same holds
|
|
347
|
+
for `skip`:
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
import { describe, it } from 'bun:test';
|
|
351
|
+
import { describeMailer, type MailerHarness } from '@nxgt/mail/conformance';
|
|
352
|
+
|
|
353
|
+
declare const harness: MailerHarness;
|
|
354
|
+
|
|
355
|
+
describeMailer({
|
|
356
|
+
name: 'my transport',
|
|
357
|
+
harness,
|
|
358
|
+
runner: { describe, it },
|
|
359
|
+
skip: { 'send.recipients': 'the sandbox accepts one recipient per message' },
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
## Without `describeMailer`
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
function runMailerCase(
|
|
367
|
+
mailerCase: MailerCase,
|
|
368
|
+
harness: MailerHarness,
|
|
369
|
+
): Promise<{ readonly skipped: string } | { readonly passed: true }>;
|
|
370
|
+
|
|
371
|
+
interface MailerCase {
|
|
372
|
+
readonly id: string; // unique and stable: 'send.deliversBytes', 'failure.outage'
|
|
373
|
+
readonly title: string; // what the case proves, as a sentence
|
|
374
|
+
readonly needs?: 'faults'; // present when the case can only run with MailerFaults
|
|
375
|
+
run(context: MailerCaseContext): Promise<void>; // throws on failure, resolves on success
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
interface MailerCaseContext {
|
|
379
|
+
readonly mailer: Mailer;
|
|
380
|
+
delivered(): Promise<readonly DeliveredMail[]>;
|
|
381
|
+
readonly faults: MailerFaults | null; // null, never undefined, when the harness has none
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`runMailerCase(case, harness)` opens the harness, builds the
|
|
386
|
+
`MailerCaseContext`, runs the case, and closes the harness, pass or fail — a
|
|
387
|
+
close that fails after a failed case does not hide the case's error. It answers
|
|
388
|
+
`{ passed: true }`, or `{ skipped: reason }` when the case needs faults the
|
|
389
|
+
harness does not have; it throws when the case fails. The cases depend on no
|
|
390
|
+
assertion library, so they run under any framework, or none:
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
import { allMailerCases, referenceMailerHarness, runMailerCase } from '@nxgt/mail/conformance';
|
|
394
|
+
|
|
395
|
+
for (const mailerCase of allMailerCases) {
|
|
396
|
+
const result = await runMailerCase(mailerCase, referenceMailerHarness());
|
|
397
|
+
console.log(mailerCase.id, 'skipped' in result ? `skipped: ${result.skipped}` : 'passed');
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
The reasons a case can be skipped for are exported as `MAILER_SKIP_REASONS`.
|
|
402
|
+
Its one entry, `MAILER_SKIP_REASONS.faults`, is the text `runMailerCase` answers
|
|
403
|
+
as `skipped`, and the text `describeMailer` puts in a skipped test's title —
|
|
404
|
+
`failure.outage: … (skipped: faults not provided: the failure contract is not
|
|
405
|
+
proven for this transport)`. Compare against the constant, not a copy of the
|
|
406
|
+
text:
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
import { allMailerCases, MAILER_SKIP_REASONS, type MailerHarness, runMailerCase } from '@nxgt/mail/conformance';
|
|
410
|
+
|
|
411
|
+
declare const harnessWithoutFaults: MailerHarness; // yours
|
|
412
|
+
|
|
413
|
+
const outage = allMailerCases.find((c) => c.id === 'failure.outage');
|
|
414
|
+
if (outage !== undefined) {
|
|
415
|
+
const result = await runMailerCase(outage, harnessWithoutFaults);
|
|
416
|
+
if ('skipped' in result && result.skipped === MAILER_SKIP_REASONS.faults) {
|
|
417
|
+
console.warn('the failure contract is not proven: add faults to the harness');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Calling a case directly — to run it under your own reporting, say — takes
|
|
423
|
+
a context you build from an opened harness:
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
import type { MailerCase, MailerCaseContext, MailerHarness } from '@nxgt/mail/conformance';
|
|
427
|
+
|
|
428
|
+
export async function runDirectly(mailerCase: MailerCase, harness: MailerHarness): Promise<void> {
|
|
429
|
+
const opened = await harness.open();
|
|
430
|
+
const context: MailerCaseContext = {
|
|
431
|
+
mailer: opened.mailer,
|
|
432
|
+
delivered: () => opened.delivered(),
|
|
433
|
+
faults: opened.faults ?? null,
|
|
434
|
+
};
|
|
435
|
+
try {
|
|
436
|
+
await mailerCase.run(context);
|
|
437
|
+
} finally {
|
|
438
|
+
await opened.close?.();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
`referenceMailerHarness()` is the memory mailer's harness — the transport the
|
|
444
|
+
suite is proven against, and a second worked example of a harness.
|
|
445
|
+
|
|
446
|
+
## See also
|
|
447
|
+
|
|
448
|
+
- [Sending](sending.md) — the message shape and the errors, from the caller's
|
|
449
|
+
side.
|
|
450
|
+
- [Testing](testing.md) — the memory mailer, when you test an application
|
|
451
|
+
rather than a transport.
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
Where `@nxgt/mail` and the packages around it are heading. A direction, not a
|
|
4
|
+
commitment: there are no dates here, and the version something shipped in is
|
|
5
|
+
the only number.
|
|
6
|
+
|
|
7
|
+
## Now
|
|
8
|
+
|
|
9
|
+
Nothing between releases.
|
|
10
|
+
|
|
11
|
+
## Next
|
|
12
|
+
|
|
13
|
+
Nothing yet.
|
|
14
|
+
|
|
15
|
+
## Later
|
|
16
|
+
|
|
17
|
+
- **More transports** — Amazon SES, Postmark and Mailgun, one package each,
|
|
18
|
+
each passing the conformance suite and throwing `@nxgt/mail`'s errors.
|
|
19
|
+
|
|
20
|
+
## Not planned
|
|
21
|
+
|
|
22
|
+
- **A preview server of our own** — `maizzle serve` is the preview: with the
|
|
23
|
+
i18n plugin it shows every e-mail in every locale, live. The packages add to
|
|
24
|
+
a Maizzle project; they never replace its commands.
|
|
25
|
+
- **A template engine at run time** — no Handlebars, no MJML, no Maizzle in
|
|
26
|
+
your server. An engine is a run-time dependency for work `maizzle build`
|
|
27
|
+
already finishes; the renderer only fills `{{ placeholder }}` values into
|
|
28
|
+
the built files.
|
|
29
|
+
- **One HTML file per language** — a layout fix would be made once per
|
|
30
|
+
language, or made once and forgotten. One template per e-mail holds keys
|
|
31
|
+
into catalogues; a new language is one catalogue.
|
|
32
|
+
- **Raw (unescaped) interpolation in v1** — every value is HTML-escaped in
|
|
33
|
+
`html`. An escape hatch is where an injection gets in; if you need markup,
|
|
34
|
+
put it in the template, or write that e-mail by hand — any function
|
|
35
|
+
answering `Rendered` is accepted.
|
|
36
|
+
- **Silent retries inside a transport** — a transport tries once and throws;
|
|
37
|
+
the conformance suite fails one that retries in secret. Whether and when to
|
|
38
|
+
retry is the caller's decision (a queue, a job runner), and a hidden retry
|
|
39
|
+
can send the same e-mail twice.
|
|
40
|
+
- **Answering `false`, or logging and resolving, on a failed send** — a
|
|
41
|
+
caller that reads a failed send as "sent" tells a user to check an inbox
|
|
42
|
+
that will stay empty. A failure throws `MailFailure`.
|
|
43
|
+
- **Falling back to the raw message when one fails to format** — that sends an
|
|
44
|
+
e-mail with `{link}` in it. A catalogue problem fails the build instead.
|
|
45
|
+
- **A transport's own error class** — a transport throws `@nxgt/mail`'s
|
|
46
|
+
`MailFailure` and `MailRefused`, so `instanceof` holds whichever transport
|
|
47
|
+
you wire.
|
|
48
|
+
- **A display name inside an address string** — `"Ada <ada@example.com>"` is
|
|
49
|
+
refused; write `{ name: 'Ada', address: 'ada@example.com' }`. A transport
|
|
50
|
+
never parses an address, and a name cannot smuggle a second one into a
|
|
51
|
+
header.
|
|
52
|
+
- **`snake_case` keys** — options, variables, catalogue keys and theme tokens
|
|
53
|
+
are `camelCase`, held by a lint rule. Error codes are `SCREAMING_SNAKE`
|
|
54
|
+
because they are values, not keys.
|
|
55
|
+
- **`moduleResolution: "nodenext"`** — sources and emitted declarations import
|
|
56
|
+
without extensions, and resolve as Bun and every bundler do. Use
|
|
57
|
+
`"moduleResolution": "bundler"`.
|
|
58
|
+
|
|
59
|
+
## Shipped
|
|
60
|
+
|
|
61
|
+
The last ten, newest first, each with the version it came in. Everything
|
|
62
|
+
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
63
|
+
|
|
64
|
+
- **The run-time core, v0.1.0** — `@nxgt/mail`, with no dependency: the `Mailer` port
|
|
65
|
+
a transport implements, the `Rendered` and `MailMessage` shapes it sends, and
|
|
66
|
+
its two errors — `MailFailure` (`MAIL_FAILED`) when the transport could not
|
|
67
|
+
hand the message over, `MailRefused` (`MAIL_REFUSED`) when the message itself
|
|
68
|
+
was refused. A send resolves only once the transport has accepted the
|
|
69
|
+
e-mail; it never answers `false`.
|
|
70
|
+
- **A memory transport for tests, v0.1.0** — `createMemoryMailer()`: an outbox you can
|
|
71
|
+
read (`mailer.sent`), and a next send you can make fail, to test the path
|
|
72
|
+
where an e-mail does not go.
|
|
73
|
+
- **Choosing the recipient's locale, v0.1.0** — `pickLocale(wanted, supported,
|
|
74
|
+
fallback)` and `parseAcceptLanguage()`: a stored preference first, then the
|
|
75
|
+
browser's languages, `fr-CA` matching `fr`, the fallback when nothing does.
|
|
76
|
+
- **A conformance suite for transport authors, v0.1.0** — `@nxgt/mail/conformance`:
|
|
77
|
+
`describeMailer(harness)` checks that a send answers `SentMail`, that an
|
|
78
|
+
outage throws a `MailFailure` which `instanceof` recognises, that an e-mail
|
|
79
|
+
arrives byte for byte, and that nothing is retried in secret. Runs under
|
|
80
|
+
bun:test, Vitest or Jest.
|
|
81
|
+
- **The run-time renderer, v0.1.0** — `createMailRenderer`, from `@nxgt/mail/renderer`:
|
|
82
|
+
`mails.render('verify-email', { name, link })` answers `Rendered` from the
|
|
83
|
+
built files of the recipient's locale, every `{{ placeholder }}` filled.
|
|
84
|
+
Values are HTML-escaped in `html`; a link that is not `http:`, `https:` or
|
|
85
|
+
`mailto:` is refused with `MailRefused`; a missing variable, an unknown
|
|
86
|
+
e-mail or locale throws. Its own entry because it reads files with
|
|
87
|
+
`node:fs`: `@nxgt/mail` itself runs anywhere.
|
|
88
|
+
- **A renderer typed by the build, v0.1.0** — `createMailRenderer<MailEmails>(…)`,
|
|
89
|
+
with the `MailEmails` that `@nxgt/mail-i18n` writes in `generated/mail.ts`:
|
|
90
|
+
an unknown e-mail, a variable missing or unknown, the variables left out, or
|
|
91
|
+
a number for a URL is a compile error rather than a throw at the send, in a
|
|
92
|
+
call written out. The
|
|
93
|
+
type parameter is optional; untyped, the renderer is unchanged, and the
|
|
94
|
+
run-time checks hold either way.
|
|
95
|
+
- **Two transports, `@nxgt/mail-smtp` and `@nxgt/mail-resend` v0.1.0** —
|
|
96
|
+
SMTP on the `nodemailer` you install, and Resend over `fetch` with no SDK,
|
|
97
|
+
each passing the conformance suite — against a local SMTP server, and a
|
|
98
|
+
local server answering as Resend does — and throwing `@nxgt/mail`'s errors.
|
|
99
|
+
See [the SMTP roadmap](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-smtp/docs/roadmap.md)
|
|
100
|
+
and [the Resend roadmap](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-resend/docs/roadmap.md).
|
|
101
|
+
- **The Maizzle side, `@nxgt/mail-config`, `@nxgt/mail-i18n`, `@nxgt/mail-ui`
|
|
102
|
+
and `@nxgt/mail-presets` v0.1.0** — packages for a normal Maizzle 6 project:
|
|
103
|
+
`defineMailConfig({ plugins })` with every plugin's build hooks chained;
|
|
104
|
+
one template per e-mail, its text keys into ICU catalogues checked at build
|
|
105
|
+
time, one output per locale and the manifest this renderer reads; e-mail
|
|
106
|
+
components in the style of `@nxgt/material-vue`, with shared messages in
|
|
107
|
+
`en` and `fr`; and nine ready e-mails built with your own brand.
|
|
108
|
+
- **A starter that sends, with v0.1.0** — `examples/starter`'s `send.ts` renders its
|
|
109
|
+
e-mails in `en` and `fr` through `createMailRenderer<MailEmails>` and
|
|
110
|
+
hands them to `createMemoryMailer()`, run in CI:
|
|
111
|
+
[`examples/starter`](https://github.com/softistx/nxgt-mail/tree/develop/examples/starter).
|
|
112
|
+
In the repository; its README says how to start your own from npm.
|