@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,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, `&amp;` 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.
@@ -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.