@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,325 @@
|
|
|
1
|
+
# Sending
|
|
2
|
+
|
|
3
|
+
This page is for calling `mailer.send`: the shape of what it takes, the
|
|
4
|
+
addresses and headers it accepts, what it answers, and what it throws.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { createMemoryMailer } from '@nxgt/mail';
|
|
8
|
+
|
|
9
|
+
const mailer = createMemoryMailer(); // in production, a transport's mailer
|
|
10
|
+
|
|
11
|
+
const sent = await mailer.send({
|
|
12
|
+
to: 'ada@example.com',
|
|
13
|
+
from: { name: 'Example', address: 'noreply@example.com' },
|
|
14
|
+
subject: 'Your password was changed',
|
|
15
|
+
html: '<p>Your password was changed.</p>',
|
|
16
|
+
text: 'Your password was changed.',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
sent.messageId; // 'memory-1'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## The port
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
interface Mailer {
|
|
26
|
+
send(message: MailMessage): Promise<SentMail>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SentMail {
|
|
30
|
+
readonly messageId: string | null;
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`send` resolves **only once the transport has handed the e-mail over** to its
|
|
35
|
+
provider. `messageId` is the id the provider gave it, or `null` when it gives
|
|
36
|
+
none — an absence, not a failure. It is not a promise the e-mail reached an
|
|
37
|
+
inbox: a bounce happens after the hand-over, and `send` does not report it.
|
|
38
|
+
|
|
39
|
+
Anything else rejects, with one of the two errors below. A mailer never
|
|
40
|
+
resolves `false` and never logs and resolves.
|
|
41
|
+
|
|
42
|
+
Depend on `Mailer`, not on a transport, so a test can pass the memory mailer
|
|
43
|
+
where production passes SMTP:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import type { Mailer } from '@nxgt/mail';
|
|
47
|
+
|
|
48
|
+
export class Accounts {
|
|
49
|
+
constructor(private readonly mailer: Mailer) {}
|
|
50
|
+
|
|
51
|
+
async passwordChanged(user: { email: string }): Promise<void> {
|
|
52
|
+
await this.mailer.send({
|
|
53
|
+
to: user.email,
|
|
54
|
+
subject: 'Your password was changed',
|
|
55
|
+
html: '<p>Your password was changed.</p>',
|
|
56
|
+
text: 'Your password was changed.',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## The message
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
interface Rendered {
|
|
66
|
+
readonly subject: string;
|
|
67
|
+
readonly html: string;
|
|
68
|
+
readonly text: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface MailMessage extends Rendered {
|
|
72
|
+
readonly to: Address | readonly Address[];
|
|
73
|
+
readonly from?: Address;
|
|
74
|
+
readonly replyTo?: Address;
|
|
75
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Field | Type | Required | Effect |
|
|
80
|
+
| --- | --- | --- | --- |
|
|
81
|
+
| `subject` | `string` | yes | The subject line. No line break: a line break in a subject is a header injection |
|
|
82
|
+
| `html` | `string` | yes | The HTML part, sent as is — escaping is the renderer's job |
|
|
83
|
+
| `text` | `string` | yes | The plain-text part. Every e-mail has one |
|
|
84
|
+
| `to` | `Address \| readonly Address[]` | yes | One recipient or several, at least one |
|
|
85
|
+
| `from` | `Address` | no | The sender. `checkMessage` does not require one: a transport is usually wired with a default sender, and one without a default may refuse a message without `from` — see its documentation |
|
|
86
|
+
| `replyTo` | `Address` | no | Where replies go |
|
|
87
|
+
| `headers` | `Record<string, string>` | no | Extra headers, such as `List-Unsubscribe` |
|
|
88
|
+
|
|
89
|
+
`Rendered` is what the renderer answers — `mails.render('verify-email', { name, link })`
|
|
90
|
+
fills the values only known at send time into a built Maizzle template — and a
|
|
91
|
+
rendered e-mail is spread into the message and addressed:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { type Mailer } from '@nxgt/mail';
|
|
95
|
+
import { createMailRenderer } from '@nxgt/mail/renderer';
|
|
96
|
+
|
|
97
|
+
const mails = createMailRenderer({ dir: 'dist' });
|
|
98
|
+
|
|
99
|
+
export async function sendVerification(mailer: Mailer, to: string, name: string, link: string): Promise<void> {
|
|
100
|
+
await mailer.send({ to, ...mails.render('verify-email', { name, link }) });
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See [Rendering](rendering.md). Any function answering the same shape fits too:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import type { Mailer, Rendered } from '@nxgt/mail';
|
|
108
|
+
|
|
109
|
+
// Hand-written: any function answering Rendered is accepted.
|
|
110
|
+
function welcome(): Rendered {
|
|
111
|
+
return {
|
|
112
|
+
subject: 'Welcome aboard',
|
|
113
|
+
html: '<p>Welcome aboard.</p>',
|
|
114
|
+
text: 'Welcome aboard.',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function sendWelcome(mailer: Mailer, email: string): Promise<void> {
|
|
119
|
+
await mailer.send({ ...welcome(), to: email });
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Escaping is the renderer's job: `createMailRenderer` HTML-escapes every value
|
|
124
|
+
it fills into `html` ([Rendering — escaping](rendering.md#escaping)). A
|
|
125
|
+
hand-written function that interpolates a value must escape it in `html`
|
|
126
|
+
itself.
|
|
127
|
+
|
|
128
|
+
## Addresses
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
type Address = string | { readonly name: string; readonly address: string };
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A string is **only** an address. A display name goes in the object form, so a
|
|
135
|
+
transport never parses one — and a string a parser would read as more than
|
|
136
|
+
one mailbox is refused. A name is free text — `Doe, John` is a name — and
|
|
137
|
+
is refused only when it holds a line break; quoting or encoding it in a header
|
|
138
|
+
is the transport's job, which the conformance suite checks:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import type { MailMessage, Rendered } from '@nxgt/mail';
|
|
142
|
+
|
|
143
|
+
declare const rendered: Rendered;
|
|
144
|
+
|
|
145
|
+
const message: MailMessage = {
|
|
146
|
+
...rendered,
|
|
147
|
+
to: ['ada@example.com', { name: 'Grace Hopper', address: 'grace@example.com' }],
|
|
148
|
+
from: { name: 'Example', address: 'noreply@example.com' },
|
|
149
|
+
replyTo: 'support@example.com',
|
|
150
|
+
};
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
What is checked is deliberately loose: one `@`, something on each side, and
|
|
154
|
+
none of what an address list reads as structure — no whitespace, no angle
|
|
155
|
+
bracket, no `,` or `;` (a second address), no `:` (a group). Whether the
|
|
156
|
+
mailbox exists is the receiving server's question.
|
|
157
|
+
|
|
158
|
+
| Written | Answer |
|
|
159
|
+
| --- | --- |
|
|
160
|
+
| `'ada@example.com'` | accepted |
|
|
161
|
+
| `{ name: 'Ada', address: 'ada@example.com' }` | accepted |
|
|
162
|
+
| `'Ada <ada@example.com>'` | `MailRefused`: `send: to is not an e-mail address` |
|
|
163
|
+
| `'root,ada@example.com'` | `MailRefused`: `send: to is not an e-mail address` — a provider parsing it would send to `ada` alone, or to two mailboxes |
|
|
164
|
+
| `{ name: 'Doe, John', address: 'john@example.com' }` | accepted: a name is free text |
|
|
165
|
+
| no `to`, or `to: []` | `MailRefused`: `send: to must hold at least one address` |
|
|
166
|
+
| `to: [undefined]` | `MailRefused`: `send: to[0] is not an e-mail address` |
|
|
167
|
+
| `{ name: 'A\r\nBcc: x@y.z', address: 'a@b.c' }` in `from` | `MailRefused`: `send: from.name must be a string without a line break` |
|
|
168
|
+
| `{ name: 'Ada' }` | a compile error: `address` is required |
|
|
169
|
+
|
|
170
|
+
`addressOf(address)` answers the bare address of either form, and
|
|
171
|
+
`recipientsOf(message)` every recipient's, in order — mostly useful to a
|
|
172
|
+
transport:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { addressOf, recipientsOf } from '@nxgt/mail';
|
|
176
|
+
|
|
177
|
+
addressOf({ name: 'Ada', address: 'ada@example.com' }); // 'ada@example.com'
|
|
178
|
+
recipientsOf({
|
|
179
|
+
to: ['a@example.com', { name: 'B', address: 'b@example.com' }],
|
|
180
|
+
subject: 's',
|
|
181
|
+
html: 'h',
|
|
182
|
+
text: 't',
|
|
183
|
+
}); // ['a@example.com', 'b@example.com']
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Headers
|
|
187
|
+
|
|
188
|
+
A header name is letters, digits and hyphens; neither a name nor a value may
|
|
189
|
+
hold a line break. What the transport writes from the message — the
|
|
190
|
+
addresses, the subject, the MIME structure — cannot be set here: `To`, `Cc`,
|
|
191
|
+
`Bcc`, `From`, `Sender`, `Reply-To`, `Return-Path`, `Subject`,
|
|
192
|
+
`MIME-Version` and any `Content-*` are refused, in any case. A `Bcc` among
|
|
193
|
+
the headers would reach an SMTP envelope as a recipient no check saw:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import type { MailMessage, Rendered } from '@nxgt/mail';
|
|
197
|
+
|
|
198
|
+
declare const rendered: Rendered;
|
|
199
|
+
|
|
200
|
+
const message: MailMessage = {
|
|
201
|
+
...rendered,
|
|
202
|
+
to: 'ada@example.com',
|
|
203
|
+
headers: {
|
|
204
|
+
'List-Unsubscribe': '<https://example.com/unsubscribe?u=42>',
|
|
205
|
+
'X-Entity-Ref-ID': 'welcome-42',
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
| Written | Answer |
|
|
211
|
+
| --- | --- |
|
|
212
|
+
| `{ 'X Bad': 'v' }` | `MailRefused`: `send: a header name must be letters, digits and hyphens` |
|
|
213
|
+
| `{ 'X-Ref': 'a\r\nb' }` | `MailRefused`: `send: header X-Ref must be a string without a line break` |
|
|
214
|
+
| `{ Bcc: 'eve@example.com' }` | `MailRefused`: `send: header Bcc is reserved — addresses, the subject and the MIME structure are never custom headers` |
|
|
215
|
+
| `{ 'content-type': 'text/plain' }` | `MailRefused`: `send: header content-type is reserved — …` |
|
|
216
|
+
|
|
217
|
+
## Errors
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
type MailErrorCode = 'MAIL_FAILED' | 'MAIL_REFUSED';
|
|
221
|
+
|
|
222
|
+
interface MailErrorOptions {
|
|
223
|
+
readonly cause?: unknown; // the error that caused this one, typically the transport's
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
abstract class MailError extends Error {
|
|
227
|
+
abstract readonly code: MailErrorCode;
|
|
228
|
+
constructor(message: string, options?: MailErrorOptions);
|
|
229
|
+
}
|
|
230
|
+
class MailFailure extends MailError {
|
|
231
|
+
readonly code: 'MAIL_FAILED';
|
|
232
|
+
}
|
|
233
|
+
class MailRefused extends MailError {
|
|
234
|
+
readonly code: 'MAIL_REFUSED';
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
| Code | Class | When | Sending it again |
|
|
239
|
+
| --- | --- | --- | --- |
|
|
240
|
+
| `MAIL_FAILED` | `MailFailure` | The transport could not hand the e-mail over: a refused connection, a timeout, a 5xx from the provider, an expired credential. The transport's error is the `cause`. **Nothing is known to have been sent**: after a timeout or a dropped connection the provider may have taken it all the same | May work later. Never report it as sent |
|
|
241
|
+
| `MAIL_REFUSED` | `MailRefused` | The e-mail itself was refused, before or by the transport: no recipient, something that is not an address, a line break in the subject or a header, a reserved header, or the provider answering that the message is malformed | Fails again, unchanged |
|
|
242
|
+
|
|
243
|
+
`MailError` is **abstract**: catch it, test `instanceof MailError`, but
|
|
244
|
+
`new MailError(…)` does not compile — a bare one would pass a `code` check and
|
|
245
|
+
fail `instanceof MailFailure`. What is thrown is always one of the two
|
|
246
|
+
subclasses. `MailError` extends `Error`, not `TypeError`, so a `catch` needs no
|
|
247
|
+
ordering.
|
|
248
|
+
`code` is a union, so a `switch` over it is exhaustive: when a code is added, a
|
|
249
|
+
function like `statusOf` below stops compiling instead of answering
|
|
250
|
+
`undefined`.
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import { MailError, type MailErrorCode, type Mailer, type MailMessage } from '@nxgt/mail';
|
|
254
|
+
|
|
255
|
+
export function statusOf(code: MailErrorCode): number {
|
|
256
|
+
switch (code) {
|
|
257
|
+
case 'MAIL_FAILED':
|
|
258
|
+
return 503;
|
|
259
|
+
case 'MAIL_REFUSED':
|
|
260
|
+
return 422;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function sendOrRespond(mailer: Mailer, message: MailMessage): Promise<Response> {
|
|
265
|
+
try {
|
|
266
|
+
await mailer.send(message);
|
|
267
|
+
return new Response(null, { status: 202 });
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (!(error instanceof MailError)) throw error;
|
|
270
|
+
return Response.json({ code: error.code }, { status: statusOf(error.code) });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**An error's `message` reports a shape, never a value.** It names where the
|
|
276
|
+
problem is — `send: to[1] is not an e-mail address` — and never the address,
|
|
277
|
+
the subject or a link: the link in a verification e-mail is a credential, and
|
|
278
|
+
an address is personal data. What the provider said is on `cause`, for your
|
|
279
|
+
logs.
|
|
280
|
+
|
|
281
|
+
A refusal that can only come from how the application was wired — a bad option
|
|
282
|
+
passed to a transport's factory — is a bare `TypeError`. No request handler
|
|
283
|
+
should answer one, so none needs to tell it apart.
|
|
284
|
+
|
|
285
|
+
## A realistic case — a sign-up handler
|
|
286
|
+
|
|
287
|
+
The account is created whatever happens to the e-mail; what the visitor is told
|
|
288
|
+
depends on whether the e-mail left:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
import { MailError, type Mailer } from '@nxgt/mail';
|
|
292
|
+
import { type MailRenderer } from '@nxgt/mail/renderer';
|
|
293
|
+
|
|
294
|
+
// Yours: your user store, your token issuer.
|
|
295
|
+
declare function createUser(email: string): Promise<{ id: string; email: string; name: string }>;
|
|
296
|
+
declare function issueVerificationToken(userId: string): Promise<string>;
|
|
297
|
+
|
|
298
|
+
export function signUpHandler(mailer: Mailer, mails: MailRenderer) {
|
|
299
|
+
return async (request: Request): Promise<Response> => {
|
|
300
|
+
const { email } = (await request.json()) as { email: string };
|
|
301
|
+
const user = await createUser(email);
|
|
302
|
+
const token = await issueVerificationToken(user.id);
|
|
303
|
+
const link = `https://app.example.com/verify?token=${encodeURIComponent(token)}`;
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
// Inside the try: render throws MailRefused for a link that is not a safe URL.
|
|
307
|
+
await mailer.send({ to: user.email, ...mails.render('verify-email', { name: user.name, link }) });
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (!(error instanceof MailError)) throw error;
|
|
310
|
+
// MAIL_FAILED: offer "send it again" later. MAIL_REFUSED: the address is unusable.
|
|
311
|
+
return Response.json({ userId: user.id, emailSent: false, code: error.code }, { status: 201 });
|
|
312
|
+
}
|
|
313
|
+
return Response.json({ userId: user.id, emailSent: true }, { status: 201 });
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## See also
|
|
319
|
+
|
|
320
|
+
- [Rendering](rendering.md) — filling a Maizzle build into the `Rendered` a message spreads.
|
|
321
|
+
- [Testing](testing.md) — the memory mailer, and making a send fail on purpose.
|
|
322
|
+
- [Locales](locales.md) — choosing the locale an e-mail is rendered in.
|
|
323
|
+
- [Writing a transport](transports.md) — implementing the port.
|
|
324
|
+
- [Troubleshooting](../troubleshooting.md) — each error message, its cause and
|
|
325
|
+
its fix.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Testing with the memory mailer
|
|
2
|
+
|
|
3
|
+
This page is for testing code that sends e-mail: `createMemoryMailer()` keeps
|
|
4
|
+
what it accepts in an outbox, refuses what every transport refuses, and can be
|
|
5
|
+
told to fail, so a test proves what your code does when a send throws.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { expect, it } from 'bun:test';
|
|
9
|
+
import { createMemoryMailer } from '@nxgt/mail';
|
|
10
|
+
|
|
11
|
+
it('sends one e-mail', async () => {
|
|
12
|
+
const mailer = createMemoryMailer();
|
|
13
|
+
|
|
14
|
+
await mailer.send({ to: 'ada@example.com', subject: 'Hello', html: '<p>Hello</p>', text: 'Hello' });
|
|
15
|
+
|
|
16
|
+
expect(mailer.sent.map((mail) => [mail.messageId, mail.subject])).toEqual([['memory-1', 'Hello']]);
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## The signature
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
function createMemoryMailer(): MemoryMailer;
|
|
24
|
+
|
|
25
|
+
interface MemoryMailer extends Mailer {
|
|
26
|
+
readonly sent: readonly MemoryMail[];
|
|
27
|
+
readonly attempts: number;
|
|
28
|
+
failNext(error?: MailError): void;
|
|
29
|
+
clear(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface MemoryMail extends MailMessage {
|
|
33
|
+
readonly messageId: string;
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A `MemoryMailer` **is** a `Mailer`: pass it wherever your code takes the port.
|
|
38
|
+
It takes no option.
|
|
39
|
+
|
|
40
|
+
## `sent` — the outbox
|
|
41
|
+
|
|
42
|
+
Every message accepted so far, oldest first, each with the id it was answered:
|
|
43
|
+
`memory-1`, `memory-2`, … The counter is per mailer.
|
|
44
|
+
|
|
45
|
+
`sent` is **a copy** on every read: mutating it, or the message you passed to
|
|
46
|
+
`send`, changes nothing in the outbox.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { createMemoryMailer } from '@nxgt/mail';
|
|
50
|
+
|
|
51
|
+
const mailer = createMemoryMailer();
|
|
52
|
+
const headers = { 'X-Ref': 'a' };
|
|
53
|
+
await mailer.send({ to: 'ada@example.com', subject: 'Hi', html: '<p>Hi</p>', text: 'Hi', headers });
|
|
54
|
+
headers['X-Ref'] = 'changed';
|
|
55
|
+
|
|
56
|
+
mailer.sent[0]?.headers; // { 'X-Ref': 'a' }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## `failNext(error?)` — making a send fail
|
|
60
|
+
|
|
61
|
+
The next send that reaches the hand-over rejects with `error` — by default a
|
|
62
|
+
`MailFailure`, as an outage would, with a `cause` (an `Error` whose message is
|
|
63
|
+
`memory mailer: failNext`) as a real transport's has. Calls queue: two calls fail the next two
|
|
64
|
+
sends, in order. The send after them goes through.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { createMemoryMailer, MailRefused } from '@nxgt/mail';
|
|
68
|
+
|
|
69
|
+
const mailer = createMemoryMailer();
|
|
70
|
+
const refused = new MailRefused('send: the provider refused the message');
|
|
71
|
+
mailer.failNext(refused); // the first send rejects with this very error
|
|
72
|
+
mailer.failNext(); // the second with a MailFailure
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Settle the expected rejection where it is created, with `.then(ok, ko)`, so a
|
|
76
|
+
send that resolves by mistake fails the test rather than slipping past it:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { expect, it } from 'bun:test';
|
|
80
|
+
import { createMemoryMailer, MailFailure } from '@nxgt/mail';
|
|
81
|
+
|
|
82
|
+
it('fails the next send when told to', async () => {
|
|
83
|
+
const mailer = createMemoryMailer();
|
|
84
|
+
mailer.failNext();
|
|
85
|
+
|
|
86
|
+
const error = await mailer
|
|
87
|
+
.send({ to: 'ada@example.com', subject: 'Hi', html: '<p>Hi</p>', text: 'Hi' })
|
|
88
|
+
.then(() => null, (e: unknown) => e);
|
|
89
|
+
|
|
90
|
+
expect(error).toBeInstanceOf(MailFailure);
|
|
91
|
+
expect(mailer.sent).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## `attempts` — proving nothing retries in secret
|
|
96
|
+
|
|
97
|
+
How many sends reached the hand-over, failed ones included. A message refused
|
|
98
|
+
as malformed never reaches it, and a queued failure keeps waiting for one that
|
|
99
|
+
does:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { createMemoryMailer } from '@nxgt/mail';
|
|
103
|
+
|
|
104
|
+
const mailer = createMemoryMailer();
|
|
105
|
+
mailer.failNext();
|
|
106
|
+
|
|
107
|
+
await mailer
|
|
108
|
+
.send({ to: [], subject: 'Hi', html: '<p>Hi</p>', text: 'Hi' })
|
|
109
|
+
.then(() => null, (e: unknown) => e); // MailRefused: to must hold at least one address
|
|
110
|
+
|
|
111
|
+
mailer.attempts; // 0 — and the failure is still queued for the next well-formed send
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## `clear()`
|
|
115
|
+
|
|
116
|
+
Forgets the outbox, the attempts, and any queued failure. The id counter keeps
|
|
117
|
+
going, so an id is never reused within one mailer.
|
|
118
|
+
|
|
119
|
+
## What it refuses
|
|
120
|
+
|
|
121
|
+
Exactly what every transport refuses, because it calls
|
|
122
|
+
[`checkMessage`](transports.md#checkmessage-first) first: no recipient, something
|
|
123
|
+
that is not an address, a line break in a name, the subject or a header, a
|
|
124
|
+
missing part. A test that passes against the memory mailer does not pass by
|
|
125
|
+
accident a message a real transport would refuse. The full list is in
|
|
126
|
+
[Sending](sending.md#addresses).
|
|
127
|
+
|
|
128
|
+
## A realistic case — the failure path of a service
|
|
129
|
+
|
|
130
|
+
A service that depends on the port gets the memory mailer in its test, and the
|
|
131
|
+
test proves the user is told the truth when the send fails:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { describe, expect, it } from 'bun:test';
|
|
135
|
+
import { createMemoryMailer, MailError, type Mailer } from '@nxgt/mail';
|
|
136
|
+
|
|
137
|
+
// The code under test: it answers whether the e-mail left.
|
|
138
|
+
async function sendReset(mailer: Mailer, email: string): Promise<{ sent: boolean }> {
|
|
139
|
+
try {
|
|
140
|
+
await mailer.send({
|
|
141
|
+
to: email,
|
|
142
|
+
subject: 'Reset your password',
|
|
143
|
+
html: '<p>Follow the link to reset your password.</p>',
|
|
144
|
+
text: 'Follow the link to reset your password.',
|
|
145
|
+
});
|
|
146
|
+
return { sent: true };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof MailError) return { sent: false };
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe('sendReset', () => {
|
|
154
|
+
it('reports a sent e-mail as sent', async () => {
|
|
155
|
+
const mailer = createMemoryMailer();
|
|
156
|
+
expect(await sendReset(mailer, 'ada@example.com')).toEqual({ sent: true });
|
|
157
|
+
expect(mailer.sent[0]?.to).toBe('ada@example.com');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('never reports a failed e-mail as sent, and does not retry it', async () => {
|
|
161
|
+
const mailer = createMemoryMailer();
|
|
162
|
+
mailer.failNext();
|
|
163
|
+
|
|
164
|
+
expect(await sendReset(mailer, 'ada@example.com')).toEqual({ sent: false });
|
|
165
|
+
expect(mailer.sent).toEqual([]);
|
|
166
|
+
expect(mailer.attempts).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## See also
|
|
172
|
+
|
|
173
|
+
- [Sending](sending.md) — the message shape and the two errors.
|
|
174
|
+
- [Writing a transport](transports.md) — the memory mailer is also the
|
|
175
|
+
reference transport the conformance suite is proven against.
|