@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -0
  3. package/dist/chunks/index-0f7kdb8k.js +114 -0
  4. package/dist/chunks/index-0f7kdb8k.js.map +11 -0
  5. package/dist/chunks/index-vq4e9n8f.js +39 -0
  6. package/dist/chunks/index-vq4e9n8f.js.map +10 -0
  7. package/dist/chunks/index-we4n5yfz.js +28 -0
  8. package/dist/chunks/index-we4n5yfz.js.map +10 -0
  9. package/dist/conformance/assert.d.ts +12 -0
  10. package/dist/conformance/assert.d.ts.map +1 -0
  11. package/dist/conformance/cases/failure.d.ts +4 -0
  12. package/dist/conformance/cases/failure.d.ts.map +1 -0
  13. package/dist/conformance/cases/index.d.ts +6 -0
  14. package/dist/conformance/cases/index.d.ts.map +1 -0
  15. package/dist/conformance/cases/send.d.ts +4 -0
  16. package/dist/conformance/cases/send.d.ts.map +1 -0
  17. package/dist/conformance/describe.d.ts +37 -0
  18. package/dist/conformance/describe.d.ts.map +1 -0
  19. package/dist/conformance/index.d.ts +20 -0
  20. package/dist/conformance/index.d.ts.map +1 -0
  21. package/dist/conformance/index.js +297 -0
  22. package/dist/conformance/index.js.map +16 -0
  23. package/dist/conformance/reference.d.ts +7 -0
  24. package/dist/conformance/reference.d.ts.map +1 -0
  25. package/dist/conformance/sample.d.ts +4 -0
  26. package/dist/conformance/sample.d.ts.map +1 -0
  27. package/dist/conformance/types.d.ts +74 -0
  28. package/dist/conformance/types.d.ts.map +1 -0
  29. package/dist/errors.d.ts +68 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/index.d.ts +21 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +29 -0
  34. package/dist/index.js.map +9 -0
  35. package/dist/locale.d.ts +33 -0
  36. package/dist/locale.d.ts.map +1 -0
  37. package/dist/memory.d.ts +34 -0
  38. package/dist/memory.d.ts.map +1 -0
  39. package/dist/message.d.ts +23 -0
  40. package/dist/message.d.ts.map +1 -0
  41. package/dist/renderer.d.ts +80 -0
  42. package/dist/renderer.d.ts.map +1 -0
  43. package/dist/renderer.js +169 -0
  44. package/dist/renderer.js.map +10 -0
  45. package/dist/types.d.ts +69 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/docs/README.md +16 -0
  48. package/docs/guide/locales.md +141 -0
  49. package/docs/guide/rendering.md +523 -0
  50. package/docs/guide/sending.md +325 -0
  51. package/docs/guide/testing.md +175 -0
  52. package/docs/guide/transports.md +451 -0
  53. package/docs/roadmap.md +112 -0
  54. package/docs/troubleshooting.md +1330 -0
  55. 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.