@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,1330 @@
1
+ # Troubleshooting `@nxgt/mail`
2
+
3
+ Each entry is headed by the text you see: a compiler error, a message, or an
4
+ error `code`. Search this page for the words of your message.
5
+
6
+ How the messages are shaped:
7
+
8
+ - **A message names where the problem is, never the value.** `send: to is
9
+ not an e-mail address` does not print the address; the link in a
10
+ verification e-mail is a credential, and it never reaches a log through an
11
+ error.
12
+ - **Every message starts with the call you wrote**: `send: …`,
13
+ `createMailRenderer: …`, `render: …`, `pickLocale: …`, `describeMailer: …`.
14
+ A conformance case that fails starts with `conformance: …`.
15
+ - **A `TypeError` is a wiring mistake**: it comes from how the application
16
+ was put together — or, from `render`, from how the call was written —
17
+ never from what a recipient did. Fix the code; no handler should answer
18
+ one.
19
+ - **A plain `Error` from `createMailRenderer` or `render` is a build out of
20
+ step with the code**: a build missing, broken or older than the server, or
21
+ a name or variable the build does not have. It is fixed by rebuilding or
22
+ by fixing the call, never handled.
23
+ - **A `MailError` is a refusal at call time.** It is a `MailFailure`
24
+ (`code: 'MAIL_FAILED'`) or a `MailRefused` (`code: 'MAIL_REFUSED'`), and
25
+ the codes are a union you can `switch` on exhaustively.
26
+
27
+ ## Index
28
+
29
+ **Install and types**
30
+ - [`TS2834: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.`](#ts2834-relative-import-paths-need-explicit-file-extensions-in-ecmascript-imports-when---moduleresolution-is-node16-or-nodenext)
31
+ - [`TS2305: Module '"@nxgt/mail"' has no exported member '<name>'.`](#ts2305-module-nxgtmail-has-no-exported-member-name)
32
+ - [`TS2741: Property 'to' is missing in type '…' but required in type 'MailMessage'.`](#ts2741-property-to-is-missing-in-type--but-required-in-type-mailmessage)
33
+ - [`TS2741: Property 'text' is missing in type '…' but required in type 'MailMessage'.`](#ts2741-property-text-is-missing-in-type--but-required-in-type-mailmessage)
34
+ - [`TS2322: Type '{ name: string; }' is not assignable to type 'Address | readonly Address[]'.`](#ts2322-type--name-string--is-not-assignable-to-type-address--readonly-address)
35
+ - [`TS2741: Property 'send' is missing in type '{}' but required in type 'Mailer'.`](#ts2741-property-send-is-missing-in-type--but-required-in-type-mailer)
36
+ - [`TS2322: Type 'Promise<boolean>' is not assignable to type 'Promise<SentMail>'.`](#ts2322-type-promiseboolean-is-not-assignable-to-type-promisesentmail)
37
+ - [`TS2322: Type 'undefined' is not assignable to type 'string | null'.`](#ts2322-type-undefined-is-not-assignable-to-type-string--null)
38
+ - [`TS2345: Argument of type '"de"' is not assignable to parameter of type '"en" | "fr"'.`](#ts2345-argument-of-type-de-is-not-assignable-to-parameter-of-type-en--fr)
39
+ - [`TS2322: Type '"MAIL_BOUNCED"' is not assignable to type 'MailErrorCode'.`](#ts2322-type-mail_bounced-is-not-assignable-to-type-mailerrorcode)
40
+ - [`TS2511: Cannot create an instance of an abstract class.`](#ts2511-cannot-create-an-instance-of-an-abstract-class)
41
+ - [`TS2345: Argument of type '"verify-emial"' is not assignable to parameter of type '"sign-in-code" | "verify-email"'.`](#ts2345-argument-of-type-verify-emial-is-not-assignable-to-parameter-of-type-sign-in-code--verify-email)
42
+ - [`TS2307: Cannot find module './generated/mail' or its corresponding type declarations.`](#ts2307-cannot-find-module-generatedmail-or-its-corresponding-type-declarations)
43
+ - [`error instanceof MailFailure` is `false` for an outage](#error-instanceof-mailfailure-is-false-for-an-outage)
44
+
45
+ **Sending**
46
+ - [`MAIL_FAILED` — `MailFailure`: the transport could not hand the message over](#mail_failed--mailfailure-the-transport-could-not-hand-the-message-over)
47
+ - [`MAIL_REFUSED` — `MailRefused`: the message was refused as malformed](#mail_refused--mailrefused-the-message-was-refused-as-malformed)
48
+ - [`send: the message must be an object`](#send-the-message-must-be-an-object)
49
+ - [`send: to must hold at least one address`](#send-to-must-hold-at-least-one-address)
50
+ - [`send: <field> is not an e-mail address`](#send-field-is-not-an-e-mail-address)
51
+ - [`send: <field>.address is not an e-mail address`](#send-fieldaddress-is-not-an-e-mail-address)
52
+ - [`send: <field>.name must be a string without a line break`](#send-fieldname-must-be-a-string-without-a-line-break)
53
+ - [`send: <part> must be a string`](#send-part-must-be-a-string)
54
+ - [`send: subject must not hold a line break`](#send-subject-must-not-hold-a-line-break)
55
+ - [`send: a header name must be letters, digits and hyphens`](#send-a-header-name-must-be-letters-digits-and-hyphens)
56
+ - [`send: header <name> must be a string without a line break`](#send-header-name-must-be-a-string-without-a-line-break)
57
+ - [`send: header <name> is reserved — addresses, the subject and the MIME structure are never custom headers`](#send-header-name-is-reserved--addresses-the-subject-and-the-mime-structure-are-never-custom-headers)
58
+ - [`send: the memory mailer was told to fail this send`](#send-the-memory-mailer-was-told-to-fail-this-send)
59
+
60
+ **Locale**
61
+ - [`pickLocale: supported must hold at least one locale`](#picklocale-supported-must-hold-at-least-one-locale)
62
+ - [`pickLocale: fallback must be one of supported`](#picklocale-fallback-must-be-one-of-supported)
63
+
64
+ **Creating the renderer**
65
+ - [`createMailRenderer: options must be an object, as { dir: 'dist' }`](#createmailrenderer-options-must-be-an-object-as--dir-dist-)
66
+ - [`createMailRenderer: dir must be the folder maizzle build wrote, as dist`](#createmailrenderer-dir-must-be-the-folder-maizzle-build-wrote-as-dist)
67
+ - [`createMailRenderer: getLanguage must be a function that answers the wanted locales, as () => user.locale`](#createmailrenderer-getlanguage-must-be-a-function-that-answers-the-wanted-locales-as---userlocale)
68
+ - [`createMailRenderer: fallbackLocale must be one of the build's locales, <locales>`](#createmailrenderer-fallbacklocale-must-be-one-of-the-builds-locales-locales)
69
+ - [`createMailRenderer: <dir>/mail-manifest.json cannot be read — run maizzle build, and deploy its output folder`](#createmailrenderer-dirmail-manifestjson-cannot-be-read--run-maizzle-build-and-deploy-its-output-folder)
70
+ - [`createMailRenderer: <dir>/<locale>/<email>.html cannot be read — run maizzle build, and deploy its output folder`](#createmailrenderer-dirlocaleemailhtml-cannot-be-read--run-maizzle-build-and-deploy-its-output-folder)
71
+ - [`createMailRenderer: <dir>/mail-manifest.json is not valid JSON`](#createmailrenderer-dirmail-manifestjson-is-not-valid-json)
72
+ - [`createMailRenderer: <dir>/mail-manifest.json is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin`](#createmailrenderer-dirmail-manifestjson-is-not-a-manifest-of-nxgtmail-i18n--build-with-its-i18n-plugin)
73
+ - [`createMailRenderer: mail-manifest.json describes <email> in a shape this version does not read — rebuild with the same version of @nxgt/mail-i18n`](#createmailrenderer-mail-manifestjson-describes-email-in-a-shape-this-version-does-not-read--rebuild-with-the-same-version-of-nxgtmail-i18n)
74
+ - [`createMailRenderer: <email> has no text part in <locale> — keep Maizzle's plaintext on, as @nxgt/mail-config sets it`](#createmailrenderer-email-has-no-text-part-in-locale--keep-maizzles-plaintext-on-as-nxgtmail-config-sets-it)
75
+ - [`Could not resolve "node:fs"`, or `No such module "node:fs"`, on an edge runtime](#could-not-resolve-nodefs-or-no-such-module-nodefs-on-an-edge-runtime)
76
+
77
+ **Rendering**
78
+ - [`render: <email> is not an e-mail of the build — one of <emails>`](#render-email-is-not-an-e-mail-of-the-build--one-of-emails)
79
+ - [`render: the locale asked for is not one of the build's, <locales>`](#render-the-locale-asked-for-is-not-one-of-the-builds-locales)
80
+ - [`render: the variables of <email> must be an object, as { name: 'Ada' }`](#render-the-variables-of-email-must-be-an-object-as--name-ada-)
81
+ - [`render: <email> has no variable <key> — it takes <variables>`](#render-email-has-no-variable-key--it-takes-variables)
82
+ - [`render: <email> needs the variable <key>`](#render-email-needs-the-variable-key)
83
+ - [`render: <email>: <key> must be a string or a finite number`](#render-email-key-must-be-a-string-or-a-finite-number)
84
+ - [`render: <email>: <key> must be an http:, https: or mailto: URL`](#render-email-key-must-be-an-http-https-or-mailto-url)
85
+ - [A link breaks when its value holds `&`, `+`, `#` or `/`: `?token={{ token }}` is not percent-encoded](#a-link-breaks-when-its-value-holds----or--token-token--is-not-percent-encoded)
86
+
87
+ **Conformance (transport authors)**
88
+ - [A transport that translates its failures](#a-transport-that-translates-its-failures)
89
+ - [`describeMailer: no test runner found — pass runner: { describe, it } from your test framework`](#describemailer-no-test-runner-found--pass-runner--describe-it--from-your-test-framework)
90
+ - [`describeMailer: skip names no case: <id>`](#describemailer-skip-names-no-case-id)
91
+ - [`faults not provided: the failure contract is not proven for this transport`](#faults-not-provided-the-failure-contract-is-not-proven-for-this-transport)
92
+ - [`conformance: <send> resolved; it must reject`](#conformance-send-resolved-it-must-reject)
93
+ - [`conformance: an outage must throw MailFailure from @nxgt/mail`](#conformance-an-outage-must-throw-mailfailure-from-nxgtmail)
94
+ - [`conformance: an outage must carry the transport's error as cause`](#conformance-an-outage-must-carry-the-transports-error-as-cause)
95
+ - [`conformance: an outage must carry the code MAIL_FAILED`](#conformance-an-outage-must-carry-the-code-mail_failed)
96
+ - [`conformance: the transport retried a failed hand-over`](#conformance-the-transport-retried-a-failed-hand-over)
97
+ - [`conformance: a name let a second recipient through`](#conformance-a-name-let-a-second-recipient-through)
98
+ - [`conformance: <what>, yet something was delivered`](#conformance-what-yet-something-was-delivered)
99
+ - [Other `conformance:` messages](#other-conformance-messages)
100
+ - [A bug in `@nxgt/mail` itself](#a-bug-in-nxgtmail-itself)
101
+
102
+ ---
103
+
104
+ ## Install and types
105
+
106
+ ### `TS2834: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.`
107
+
108
+ **When:** `tsc` on your project, reported inside
109
+ `node_modules/@nxgt/mail/dist/*.d.ts`, once per import line.
110
+ **Why:** the declarations import their siblings without an extension
111
+ (`'./errors'`), the way a bundler resolves them. `moduleResolution:
112
+ "nodenext"` (or `"node16"`) demands an extension on every relative import and
113
+ is **not supported** by this package.
114
+ **Fix:** resolve as a bundler does. Bun, Vite, esbuild and every other
115
+ bundler already do:
116
+
117
+ ```jsonc
118
+ // tsconfig.json
119
+ {
120
+ "compilerOptions": {
121
+ "module": "preserve", // or "esnext"
122
+ "moduleResolution": "bundler"
123
+ }
124
+ }
125
+ ```
126
+
127
+ Do not patch the declarations to add `.js` extensions: that is out of scope,
128
+ and a patched copy breaks on the next install.
129
+
130
+ ### `TS2305: Module '"@nxgt/mail"' has no exported member '<name>'.`
131
+
132
+ **When:** `tsc` on your own file, for an export that exists: typically
133
+ `MailFailure`, `pickLocale` or `createMemoryMailer`.
134
+ **Why:** the same `moduleResolution: "nodenext"` as above, with
135
+ `skipLibCheck: true` hiding the `TS2834` errors in the declarations. The
136
+ entry declaration's re-exports do not resolve, so everything they carry is
137
+ missing.
138
+ **Fix:** `"moduleResolution": "bundler"`, as in the entry above.
139
+
140
+ The other cause: `createMailRenderer`, `MailRenderer`, `MailRendererOptions`,
141
+ `RenderOptions` or `MailVariables` imported from `@nxgt/mail`. The renderer is
142
+ its own entry, since it reads files with `node:fs`:
143
+
144
+ ```ts
145
+ import { createMailRenderer } from '@nxgt/mail/renderer';
146
+ ```
147
+
148
+ ### `TS2741: Property 'to' is missing in type '…' but required in type 'MailMessage'.`
149
+
150
+ **When:** `tsc`, where you build the message you pass to `mailer.send`,
151
+ typically by spreading a `Rendered` — what the run-time renderer answers, or
152
+ your own function.
153
+ **Why:** a `Rendered` holds `subject`, `html` and `text`, and knows nothing of
154
+ who it is for. A `MailMessage` is a `Rendered` plus at least `to`.
155
+ **Fix:**
156
+
157
+ ```ts
158
+ import type { Mailer, Rendered } from '@nxgt/mail';
159
+
160
+ declare const mailer: Mailer;
161
+ declare const rendered: Rendered;
162
+
163
+ await mailer.send({ ...rendered, to: 'ada@example.com' });
164
+ ```
165
+
166
+ ### `TS2741: Property 'text' is missing in type '…' but required in type 'MailMessage'.`
167
+
168
+ **When:** `tsc`, on a message written by hand with an `html` part only.
169
+ **Why:** every e-mail carries a plain-text part: some clients show nothing
170
+ else, and spam filters score an e-mail without one. The port has no room for
171
+ a message without it.
172
+ **Fix:** write the text part. The run-time renderer (`@nxgt/mail/renderer`)
173
+ always answers one, from the plain text Maizzle builds beside the HTML:
174
+
175
+ ```ts
176
+ import type { MailMessage } from '@nxgt/mail';
177
+
178
+ const message: MailMessage = {
179
+ to: 'ada@example.com',
180
+ subject: 'Your export is ready',
181
+ html: '<p>Your export is ready.</p>',
182
+ text: 'Your export is ready.\n',
183
+ };
184
+ ```
185
+
186
+ ### `TS2322: Type '{ name: string; }' is not assignable to type 'Address | readonly Address[]'.`
187
+
188
+ **When:** `tsc`, on a recipient written as an object.
189
+ **Why:** an `Address` is a bare string, or an object with **both** `name` and
190
+ `address`. A name alone does not say where to send.
191
+ **Fix:**
192
+
193
+ ```ts
194
+ import type { MailMessage, Rendered } from '@nxgt/mail';
195
+
196
+ declare const rendered: Rendered;
197
+
198
+ const message: MailMessage = {
199
+ ...rendered,
200
+ to: { name: 'Ada Lovelace', address: 'ada@example.com' },
201
+ };
202
+ ```
203
+
204
+ ### `TS2741: Property 'send' is missing in type '{}' but required in type 'Mailer'.`
205
+
206
+ **When:** `tsc`, on a transport or a test double typed as `Mailer`.
207
+ **Why:** `send` is the whole port.
208
+ **Fix:** implement it, or use the memory mailer in a test:
209
+
210
+ ```ts
211
+ import { createMemoryMailer, type Mailer } from '@nxgt/mail';
212
+
213
+ const mailer: Mailer = createMemoryMailer();
214
+ ```
215
+
216
+ ### `TS2322: Type 'Promise<boolean>' is not assignable to type 'Promise<SentMail>'.`
217
+
218
+ **When:** `tsc`, on a `send` that answers `true` or `false`.
219
+ **Why:** a failure throws; it never answers. A `send` that answers `false`
220
+ lets a caller report an e-mail as sent when nothing left, and the port is
221
+ typed so that it cannot.
222
+ **Fix:** resolve with a `SentMail` once the transport has accepted the
223
+ message, and throw otherwise. See
224
+ [how a transport translates a failure](#a-transport-that-translates-its-failures).
225
+
226
+ ### `TS2322: Type 'undefined' is not assignable to type 'string | null'.`
227
+
228
+ **When:** `tsc`, on the `SentMail` a transport answers, when the provider
229
+ gave no message id.
230
+ **Why:** an absence is `null`, never `undefined`.
231
+ **Fix:**
232
+
233
+ ```ts
234
+ import type { SentMail } from '@nxgt/mail';
235
+
236
+ declare const providerId: string | undefined;
237
+
238
+ const sent: SentMail = { messageId: providerId ?? null };
239
+ ```
240
+
241
+ ### `TS2345: Argument of type '"de"' is not assignable to parameter of type '"en" | "fr"'.`
242
+
243
+ **When:** `tsc`, on a `pickLocale` call whose `fallback` is not one of
244
+ `supported`.
245
+ **Why:** `pickLocale` answers one of `supported`, and the fallback is what it
246
+ answers when nothing matches, so it must be one of them. The literal types of
247
+ `supported` are what makes the compiler check it.
248
+ **Fix:**
249
+
250
+ ```ts
251
+ import { pickLocale } from '@nxgt/mail';
252
+
253
+ const locale = pickLocale('de-AT', ['en', 'fr'], 'en'); // 'en' | 'fr'
254
+ ```
255
+
256
+ When `supported` is a `string[]` built at run time, the compiler cannot check
257
+ the fallback and the call throws
258
+ [`pickLocale: fallback must be one of supported`](#picklocale-fallback-must-be-one-of-supported)
259
+ instead.
260
+
261
+ ### `TS2322: Type '"MAIL_BOUNCED"' is not assignable to type 'MailErrorCode'.`
262
+
263
+ **When:** `tsc`, on a `case` or a comparison with a code this package does
264
+ not have.
265
+ **Why:** the codes are `MAIL_FAILED` and `MAIL_REFUSED`, nothing else. A
266
+ bounce happens after the hand-over, and `send` has already resolved by then.
267
+ **Fix:** switch on the two codes; see
268
+ [`MAIL_FAILED`](#mail_failed--mailfailure-the-transport-could-not-hand-the-message-over).
269
+
270
+ ### `TS2511: Cannot create an instance of an abstract class.`
271
+
272
+ **When:** `tsc`, on `new MailError(…)`: typically in a transport, or in a
273
+ test double that should fail a send.
274
+ **Why:** `MailError` is the abstract base of the two errors a send can throw.
275
+ A bare one would pass a check on `code` and fail
276
+ `error instanceof MailFailure`, so a caller would handle an outage as an
277
+ unknown error.
278
+ **Fix:** throw the subclass that says what happened, with the provider's
279
+ error as `cause`:
280
+
281
+ ```ts
282
+ import { MailFailure, MailRefused } from '@nxgt/mail';
283
+
284
+ declare const cause: unknown;
285
+ declare const providerRefusedTheMessage: boolean;
286
+
287
+ throw providerRefusedTheMessage
288
+ ? new MailRefused('send: the provider refused the message', { cause })
289
+ : new MailFailure('send: the provider could not be reached', { cause });
290
+ ```
291
+
292
+ Keep `MailError` for `catch`: `error instanceof MailError` is true for both.
293
+
294
+ ### `TS2345: Argument of type '"verify-emial"' is not assignable to parameter of type '"sign-in-code" | "verify-email"'.`
295
+
296
+ Or, on the variables of the same call:
297
+ `TS2353: Object literal may only specify known properties, and 'name' does not exist in type '{ readonly code: string | number; }'.`,
298
+ `Property 'name' is missing in type '{ link: string; }' but required in type …`,
299
+ `TS2554: Expected 2-3 arguments, but got 1.`, or
300
+ `TS2322: Type 'number' is not assignable to type 'string'.`
301
+
302
+ **When:** `tsc`, on a `render` call of a renderer created as
303
+ `createMailRenderer<MailEmails>(…)`, typically after a template was renamed,
304
+ or a placeholder added, renamed or removed.
305
+ **Why:** `MailEmails` lists the e-mails of the last build and the variables
306
+ each takes; `render` is typed from it. The call names an e-mail or a variable
307
+ that build does not have, leaves one out, or passes a number to a URL
308
+ variable, which is a string. Untyped, the same call would throw at run time:
309
+ [`render: <email> is not an e-mail of the build`](#render-email-is-not-an-e-mail-of-the-build--one-of-emails),
310
+ [`needs the variable`](#render-email-needs-the-variable-key) or
311
+ [`has no variable`](#render-email-has-no-variable-key--it-takes-variables).
312
+ **Fix:** a typo is fixed in the call. When the templates changed, rebuild, so
313
+ that `generated/mail.ts` describes them, then fix the calls `tsc` still
314
+ reports:
315
+
316
+ ```sh
317
+ bunx maizzle build # rewrites dist/ and generated/mail.ts; commit the new generated/mail.ts
318
+ ```
319
+
320
+ Never edit `generated/mail.ts` by hand to silence the error: the next build
321
+ rewrites it, and the deployed build is what `render` checks at run time.
322
+
323
+ ### `TS2307: Cannot find module './generated/mail' or its corresponding type declarations.`
324
+
325
+ **When:** `tsc`, on `import type { MailEmails } from './generated/mail'`, in a
326
+ fresh clone or a new project.
327
+ **Why:** `generated/mail.ts` is written by `@nxgt/mail-i18n` at the end of
328
+ `maizzle build`, in the Maizzle project, unless its `rendererTypes` option
329
+ moved it or turned it off (`false`). It is not there until the first build,
330
+ or it was not committed, or the import points at another folder.
331
+ **Fix:** build once and commit the file, so the code that sends type-checks
332
+ without a build; import it from where `rendererTypes` writes it:
333
+
334
+ ```sh
335
+ bunx maizzle build
336
+ git add generated/mail.ts
337
+ ```
338
+
339
+ To go without it, leave the type parameter out:
340
+ `createMailRenderer({ dir: 'dist' })` takes any name and any
341
+ `MailVariables`, checked at run time only.
342
+
343
+ ### `error instanceof MailFailure` is `false` for an outage
344
+
345
+ **When:** at run time, with a transport from another package or your own. An
346
+ outage is then handled as an unknown error.
347
+ **Why:** one of two things.
348
+
349
+ - **The transport defined its own error class**, even one named
350
+ `MailFailure` with `code: 'MAIL_FAILED'`. `instanceof` tests the class, not
351
+ the name. A transport defines no error class; it throws the ones it imports
352
+ from `@nxgt/mail`.
353
+ - **Two copies of `@nxgt/mail` are installed**: the transport depends on it
354
+ instead of peering it, or bundled it into its own build. It throws its
355
+ copy's `MailFailure`, and your code tests against the other.
356
+
357
+ **Fix:** a transport lists `@nxgt/mail` as a peer, never a dependency, and
358
+ marks it external in its build:
359
+
360
+ ```jsonc
361
+ // the transport's package.json
362
+ {
363
+ "peerDependencies": { "@nxgt/mail": "<the range you support>" }
364
+ }
365
+ ```
366
+
367
+ Then check that only one copy is installed:
368
+
369
+ ```sh
370
+ bun pm ls --all | grep @nxgt/mail
371
+ ```
372
+
373
+ The conformance suite catches both mistakes: see
374
+ [`conformance: an outage must throw MailFailure from @nxgt/mail`](#conformance-an-outage-must-throw-mailfailure-from-nxgtmail).
375
+
376
+ ---
377
+
378
+ ## Sending
379
+
380
+ Every `send: …` message below is a `MailRefused` with `code: 'MAIL_REFUSED'`,
381
+ thrown by `checkMessage` — which every transport calls first, so the refusals
382
+ are the same whichever transport is wired. **Nothing was sent**, and sending
383
+ the same message again fails again.
384
+
385
+ ### `MAIL_FAILED` — `MailFailure`: the transport could not hand the message over
386
+
387
+ **When:** `await mailer.send(message)` rejects: a refused connection, a
388
+ timeout, a 5xx from the provider, an expired credential. The message is the
389
+ transport's; the transport's own error is `error.cause`.
390
+ **Why:** the invariant of the port: a failure throws. `send` resolves only
391
+ once the transport has accepted the message.
392
+ **Fix:** **nothing is known to have been sent** — never report it as sent.
393
+ After a timeout or a dropped connection the provider may have taken it all
394
+ the same, so a retry can deliver it twice; weigh that, then retry later, or
395
+ tell the user it failed:
396
+
397
+ ```ts
398
+ import { MailError, type Mailer, type MailMessage } from '@nxgt/mail';
399
+
400
+ declare const mailer: Mailer;
401
+ declare const message: MailMessage;
402
+
403
+ try {
404
+ await mailer.send(message);
405
+ } catch (error) {
406
+ if (!(error instanceof MailError)) throw error;
407
+ switch (error.code) {
408
+ case 'MAIL_FAILED':
409
+ // Nothing is known to have been sent: queue a retry, or tell the user it failed.
410
+ break;
411
+ case 'MAIL_REFUSED':
412
+ // The message itself is wrong: sending it again fails again.
413
+ break;
414
+ }
415
+ }
416
+ ```
417
+
418
+ Do not retry inside the transport: a retry belongs to the caller, who knows
419
+ whether the e-mail is still worth sending.
420
+
421
+ ### `MAIL_REFUSED` — `MailRefused`: the message was refused as malformed
422
+
423
+ **When:** `await mailer.send(message)` rejects, either with one of the
424
+ `send: …` messages below (the message was refused before it left), or with
425
+ the transport's message when the provider answered that the message is
426
+ malformed.
427
+ **Why:** something in the message would break a header or has no valid
428
+ recipient. Sending it again unchanged fails again.
429
+ **Fix:** read `error.message` for where the problem is, and fix the message;
430
+ the entries below cover each one. Handle the code as in the
431
+ [`MAIL_FAILED`](#mail_failed--mailfailure-the-transport-could-not-hand-the-message-over)
432
+ snippet.
433
+
434
+ ### `send: the message must be an object`
435
+
436
+ **When:** `send` is called with `null`, `undefined` or a string: typically a
437
+ value read from a queue or a JSON body without being checked.
438
+ **Why:** a message is an object with `to`, `subject`, `html` and `text`.
439
+ **Fix:** pass the message object; when it comes from outside your code,
440
+ parse it before you send it.
441
+
442
+ ### `send: to must hold at least one address`
443
+
444
+ **When:** `send` with `to: []`, or with no `to` at all (`undefined` or
445
+ `null`): typically a recipient list filtered down to nothing, or a message
446
+ built from an untyped value.
447
+ **Why:** an e-mail with no recipient goes nowhere, and a transport that
448
+ accepted it would report a send that never happened.
449
+ **Fix:** decide before the call what an empty list means for you:
450
+
451
+ ```ts
452
+ import type { Mailer, Rendered } from '@nxgt/mail';
453
+
454
+ declare const mailer: Mailer;
455
+ declare const rendered: Rendered;
456
+ declare const recipients: string[];
457
+
458
+ if (recipients.length > 0) {
459
+ await mailer.send({ ...rendered, to: recipients });
460
+ }
461
+ ```
462
+
463
+ ### `send: <field> is not an e-mail address`
464
+
465
+ `<field>` is `to`, `to[<n>]`, `from` or `replyTo`.
466
+
467
+ **When:** `send`, with a string that is not a bare address. Most often a
468
+ display name written into the string: `'Ada <ada@example.com>'`. Also a
469
+ recipient that is `undefined` or `null` inside the list — `to: [undefined]`
470
+ answers `send: to[0] is not an e-mail address` — typically a user lookup that
471
+ found no one.
472
+ Also a string holding a `,`, a `;` or a `:` — `'root,ada@example.com'`,
473
+ `'group:ada@example.com'` — often addresses joined into one string.
474
+ **Why:** a string is **only** an address — one `@`, something on each side,
475
+ no whitespace, no angle bracket, no `,` `;` or `:` — so no transport ever
476
+ parses one, and a string can never smuggle a second address in: a provider
477
+ parsing `'root,ada@example.com'` would send to two mailboxes, or to `ada`
478
+ alone. Pass several recipients as a list, `to: ['a@example.com', 'b@example.com']`.
479
+ **Fix:** put the name in an object:
480
+
481
+ ```ts
482
+ import type { MailMessage, Rendered } from '@nxgt/mail';
483
+
484
+ declare const rendered: Rendered;
485
+
486
+ const message: MailMessage = {
487
+ ...rendered,
488
+ to: { name: 'Ada Lovelace', address: 'ada@example.com' },
489
+ };
490
+ ```
491
+
492
+ The check is deliberately loose: whether the mailbox exists is the receiving
493
+ server's question.
494
+
495
+ ### `send: <field>.address is not an e-mail address`
496
+
497
+ **When:** `send`, with an `Address` object whose `address` is missing, not a
498
+ string, or not a bare address (`{ name: 'Ada', address: 'Ada <ada@…>' }`).
499
+ **Why:** the same rule as the entry above, for the object form.
500
+ **Fix:** `address` holds the bare address only; the name goes in `name`.
501
+
502
+ ### `send: <field>.name must be a string without a line break`
503
+
504
+ **When:** `send`, with an `Address` object whose `name` is missing, not a
505
+ string, or holds `\r` or `\n`: typically a name read from a user profile.
506
+ **Why:** the name is written into a header, and a line break in a header is a
507
+ header injection.
508
+ **Fix:** collapse the whitespace of a name you did not write:
509
+
510
+ ```ts
511
+ declare const displayName: string;
512
+
513
+ const name = displayName.replace(/\s+/g, ' ').trim();
514
+ ```
515
+
516
+ An empty `name` is accepted; with no name, pass the bare address string.
517
+
518
+ ### `send: <part> must be a string`
519
+
520
+ `<part>` is `subject`, `html` or `text`.
521
+
522
+ **When:** `send`, with one of the three parts missing or not a string:
523
+ typically a hand-written function that answered `undefined` for its text
524
+ part, or a message built from an untyped value.
525
+ **Why:** every e-mail has a subject, an HTML part and a text part.
526
+ **Fix:** pass the `Rendered` whole — what the renderer answered, or your own
527
+ function's, which must answer all three (see the `Rendered` type).
528
+
529
+ ### `send: subject must not hold a line break`
530
+
531
+ **When:** `send`, with `\r` or `\n` in the subject: typically a subject built
532
+ from a value a user typed, such as a name or a title.
533
+ **Why:** the subject is a header, and a line break in a header is a header
534
+ injection (`'Hello\r\nBcc: …'`).
535
+ **Fix:** collapse the whitespace of the value before it reaches the subject:
536
+
537
+ ```ts
538
+ declare const title: string;
539
+
540
+ const subject = `New comment on ${title.replace(/\s+/g, ' ').trim()}`;
541
+ ```
542
+
543
+ ### `send: a header name must be letters, digits and hyphens`
544
+
545
+ **When:** `send`, with a key in `headers` that holds a space, a colon, an
546
+ underscore or a line break.
547
+ **Why:** a header name is written as is before its `:`; anything else breaks
548
+ the header, or adds one.
549
+ **Fix:**
550
+
551
+ ```ts
552
+ import type { MailMessage, Rendered } from '@nxgt/mail';
553
+
554
+ declare const rendered: Rendered;
555
+
556
+ const message: MailMessage = {
557
+ ...rendered,
558
+ to: 'ada@example.com',
559
+ headers: { 'List-Unsubscribe': '<https://example.com/unsubscribe>' },
560
+ };
561
+ ```
562
+
563
+ ### `send: header <name> must be a string without a line break`
564
+
565
+ **When:** `send`, with a header value that is not a string or holds `\r` or
566
+ `\n`. The message names the header, never its value.
567
+ **Why:** a line break in a header value starts a new header.
568
+ **Fix:** pass a single-line string; convert a number with `String(value)`.
569
+
570
+ ### `send: header <name> is reserved — addresses, the subject and the MIME structure are never custom headers`
571
+
572
+ **When:** `send`, with a key in `headers` that names what the transport
573
+ writes from the message: `To`, `Cc`, `Bcc`, `From`, `Sender`, `Reply-To`,
574
+ `Return-Path`, `Subject`, `MIME-Version` or any `Content-*` — in any case,
575
+ `bcc` as well as `Bcc`. The message names the header, never its value.
576
+ **Why:** a header set there bypasses every check on the message. A `Bcc`
577
+ reaches an SMTP envelope as a recipient no address check saw, a second `To`
578
+ or `From` contradicts the one the transport writes, and a `Content-Type`
579
+ changes how the parts are read.
580
+ **Fix:** use the message's own fields — `to` (a list for several
581
+ recipients), `from`, `replyTo`, `subject` — and send a separate e-mail to a
582
+ recipient who must not appear to the others:
583
+
584
+ ```ts
585
+ import type { Mailer, Rendered } from '@nxgt/mail';
586
+
587
+ declare const mailer: Mailer;
588
+ declare const rendered: Rendered;
589
+
590
+ // ✗ headers: { Bcc: 'audit@example.com' }
591
+ await mailer.send({ ...rendered, to: 'ada@example.com' });
592
+ await mailer.send({ ...rendered, to: 'audit@example.com' }); // ✓ the copy, on its own
593
+ ```
594
+
595
+ ### `send: the memory mailer was told to fail this send`
596
+
597
+ **When:** a test, on a send through `createMemoryMailer()` after
598
+ `failNext()`. It is a `MailFailure`, `code: 'MAIL_FAILED'`, with a `cause`
599
+ as a real outage has, so code that logs `error.cause` is exercised too.
600
+ **Why:** that is what `failNext` is for: proving what your code does when a
601
+ send throws. Calls queue — two `failNext()` fail the next two sends — and a
602
+ message refused by `checkMessage` does not consume one.
603
+ **Fix:** expected in the test that asked for it. When it shows up in the next
604
+ test, the mailer is shared between tests: create one per test, or call
605
+ `clear()`:
606
+
607
+ ```ts
608
+ import { beforeEach } from 'bun:test';
609
+ import { createMemoryMailer } from '@nxgt/mail';
610
+
611
+ const mailer = createMemoryMailer();
612
+ beforeEach(() => mailer.clear());
613
+ ```
614
+
615
+ ---
616
+
617
+ ## Locale
618
+
619
+ ### `pickLocale: supported must hold at least one locale`
620
+
621
+ A `TypeError`.
622
+
623
+ **When:** the first `pickLocale` call, with an empty `supported` list:
624
+ typically a list read from configuration or from a catalogue folder that is
625
+ empty.
626
+ **Why:** `pickLocale` answers one of `supported`; with none, it has nothing to
627
+ answer. This is a wiring mistake, not something a recipient caused.
628
+ **Fix:** pass the locales your e-mails are built in, as literals, so the
629
+ compiler also checks the fallback:
630
+
631
+ ```ts
632
+ import { pickLocale } from '@nxgt/mail';
633
+
634
+ const supported = ['en', 'fr'] as const;
635
+
636
+ declare const storedLocale: string | null;
637
+ const locale = pickLocale(storedLocale, supported, 'en');
638
+ ```
639
+
640
+ ### `pickLocale: fallback must be one of supported`
641
+
642
+ A `TypeError`.
643
+
644
+ **When:** the first `pickLocale` call, when `supported` is a `string[]` built
645
+ at run time and does not hold `fallback`. With literal types, the same
646
+ mistake is a
647
+ [compile error](#ts2345-argument-of-type-de-is-not-assignable-to-parameter-of-type-en--fr)
648
+ instead.
649
+ **Why:** the fallback is what `pickLocale` answers when nothing matches, and
650
+ an e-mail can only be rendered in a supported locale.
651
+ **Fix:** make the fallback one of the list, or declare the list as literals:
652
+
653
+ ```ts
654
+ import { pickLocale } from '@nxgt/mail';
655
+
656
+ const supported = ['en', 'fr'] as const;
657
+
658
+ pickLocale('de', supported, 'en'); // 'en'
659
+ ```
660
+
661
+ ---
662
+
663
+ ## Creating the renderer
664
+
665
+ `createMailRenderer` reads the manifest and every file it lists **once, when
666
+ it is called**. A mistake in the options is a `TypeError`, and a missing or
667
+ broken build is an `Error`. Both are thrown there, at start-up, never at the
668
+ first send. Nothing about them can be handled: fix the wiring or the
669
+ deployment, and restart.
670
+
671
+ ### `createMailRenderer: options must be an object, as { dir: 'dist' }`
672
+
673
+ A `TypeError`.
674
+
675
+ **When:** `createMailRenderer()` with no argument, or with a string:
676
+ typically `createMailRenderer('dist')`.
677
+ **Why:** the options are one object, and `dir` is required in it.
678
+ **Fix:**
679
+
680
+ ```ts
681
+ import { createMailRenderer } from '@nxgt/mail/renderer';
682
+
683
+ const mails = createMailRenderer({ dir: 'dist' });
684
+ ```
685
+
686
+ ### `createMailRenderer: dir must be the folder maizzle build wrote, as dist`
687
+
688
+ A `TypeError`.
689
+
690
+ **When:** `createMailRenderer({})`, or `dir` set to `''`, to whitespace or
691
+ to something that is not a string: typically an environment variable that is
692
+ not set in this environment (`dir: process.env.MAIL_DIR`).
693
+ **Why:** `dir` is where `mail-manifest.json` is. There is no default, so a
694
+ deployment that forgot the variable does not silently read another folder.
695
+ **Fix:** pass the output folder of `maizzle build`. When it comes from the
696
+ environment, check that it is set before the call.
697
+
698
+ ### `createMailRenderer: getLanguage must be a function that answers the wanted locales, as () => user.locale`
699
+
700
+ A `TypeError`.
701
+
702
+ **When:** `createMailRenderer({ dir, getLanguage: user.locale })`: the locale
703
+ passed as a value rather than a function that answers it.
704
+ **Why:** the renderer is created once, at start-up, and asks `getLanguage` at
705
+ each `render`, so the recipient of each send decides the locale.
706
+ **Fix:**
707
+
708
+ ```ts
709
+ import { createMailRenderer } from '@nxgt/mail/renderer';
710
+
711
+ declare const currentUser: () => { locale: string | null };
712
+
713
+ const mails = createMailRenderer({
714
+ dir: 'dist',
715
+ getLanguage: () => currentUser().locale,
716
+ });
717
+ ```
718
+
719
+ To render one e-mail in a locale you already hold, pass it to `render`
720
+ instead: `mails.render('verify-email', variables, { locale: 'fr' })`.
721
+
722
+ ### `createMailRenderer: fallbackLocale must be one of the build's locales, <locales>`
723
+
724
+ A `TypeError`. `<locales>` lists the locales the build wrote, as `en, fr`.
725
+
726
+ **When:** `createMailRenderer({ dir, fallbackLocale: 'de' })`, when the build
727
+ has no `de`: typically a fallback copied from another project, or a locale
728
+ removed from `i18n({ locales })` without updating the server.
729
+ **Why:** the fallback is what an e-mail is rendered in when no wanted locale
730
+ was built, so it must be one of them.
731
+ **Fix:** leave `fallbackLocale` out to use the one the build was made with
732
+ (`i18n({ fallbackLocale })`), or name one of the locales in the message.
733
+
734
+ ### `createMailRenderer: <dir>/mail-manifest.json cannot be read — run maizzle build, and deploy its output folder`
735
+
736
+ An `Error`, its `cause` the file system's error (`ENOENT`, `EACCES`).
737
+
738
+ **When:** start-up, in three situations:
739
+
740
+ - `maizzle build` has not run, or wrote to another folder
741
+ (`productionConfig(config, { output: { path: 'dist-production' } })`
742
+ writes to `dist-production`, not `dist`).
743
+ - The server was deployed without the build: the image or the bundle holds
744
+ the server's code, but not the output folder.
745
+ - `dir` is relative. It resolves against the folder the process was started
746
+ from, not against the file that calls `createMailRenderer`, so the same
747
+ code works from the project root and fails from anywhere else.
748
+
749
+ **Why:** the renderer fills the files `maizzle build` wrote; it never builds
750
+ them, so the build has to be where `dir` says, next to the running server.
751
+ **Fix:** build before you deploy, ship the output folder with the server, and
752
+ resolve `dir` from the calling file rather than from the working directory:
753
+
754
+ ```ts
755
+ import { fileURLToPath } from 'node:url';
756
+ import { createMailRenderer } from '@nxgt/mail/renderer';
757
+
758
+ const mails = createMailRenderer({
759
+ // The folder `maizzle build` wrote, relative to this file.
760
+ dir: fileURLToPath(new URL('../emails/dist', import.meta.url)),
761
+ });
762
+ ```
763
+
764
+ ```dockerfile
765
+ # Next to the server's code, in the image that runs it.
766
+ COPY emails/dist ./emails/dist
767
+ ```
768
+
769
+ When the server is bundled, `new URL(…, import.meta.url)` is relative to the
770
+ bundle's file: point it at where the output folder sits in the deployment.
771
+
772
+ ### `createMailRenderer: <dir>/<locale>/<email>.html cannot be read — run maizzle build, and deploy its output folder`
773
+
774
+ The same message, for a file the manifest lists rather than the manifest
775
+ itself: `<email>.html` or `<email>.txt`, under the path the build wrote.
776
+
777
+ **When:** start-up, when the manifest was found but a file it lists was not:
778
+ the output folder was copied only in part, cleaned after the build, or the
779
+ manifest comes from a newer build than the files beside it.
780
+ **Why:** the manifest and the files are one build; the renderer reads every
781
+ file it lists at start-up, so a missing one fails there and not at the send
782
+ that needs it.
783
+ **Fix:** deploy the output folder whole, from a single `maizzle build`. Do
784
+ not copy files out of it one by one.
785
+
786
+ ### `createMailRenderer: <dir>/mail-manifest.json is not valid JSON`
787
+
788
+ An `Error`, its `cause` the `SyntaxError`.
789
+
790
+ **When:** start-up, when `mail-manifest.json` was cut short or edited: a copy
791
+ interrupted mid-way, a merge conflict in a committed build, a hand edit.
792
+ **Why:** the file is written by `@nxgt/mail-i18n` at the end of a build, and
793
+ never meant to be edited.
794
+ **Fix:** run `maizzle build` again, and deploy its output.
795
+
796
+ ### `createMailRenderer: <dir>/mail-manifest.json is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin`
797
+
798
+ An `Error`.
799
+
800
+ **When:** start-up, when `dir` points at a folder whose `mail-manifest.json`
801
+ has no `locales` list (or an empty one), no `fallbackLocale` or no `emails`
802
+ object: typically a `mail-manifest.json` written by something else.
803
+ **Why:** the renderer reads only the manifest the `i18n()` plugin of
804
+ `@nxgt/mail-i18n` writes. A Maizzle build without that plugin writes no
805
+ manifest at all, and fails with
806
+ [`cannot be read`](#createmailrenderer-dirmail-manifestjson-cannot-be-read--run-maizzle-build-and-deploy-its-output-folder)
807
+ instead.
808
+ **Fix:** build with the plugin:
809
+
810
+ ```ts
811
+ // maizzle.config.ts
812
+ import { defineMailConfig } from '@nxgt/mail-config';
813
+ import { i18n } from '@nxgt/mail-i18n';
814
+
815
+ export default defineMailConfig({
816
+ plugins: [i18n({ locales: ['en', 'fr'], fallbackLocale: 'en' })],
817
+ });
818
+ ```
819
+
820
+ ### `createMailRenderer: mail-manifest.json describes <email> in a shape this version does not read — rebuild with the same version of @nxgt/mail-i18n`
821
+
822
+ An `Error`.
823
+
824
+ **When:** start-up, when an e-mail's entry in the manifest lacks
825
+ `variables`, `urlVariables`, `subject` or `files`, or has no subject or HTML
826
+ file for one of the build's locales.
827
+ **Why:** the build and the server use versions of `@nxgt/mail-i18n` and
828
+ `@nxgt/mail` that do not agree on the manifest: a build committed or cached
829
+ from an older version, read by a newer server, or the reverse.
830
+ **Fix:** upgrade `@nxgt/mail-i18n` and `@nxgt/mail` together, then run
831
+ `maizzle build` again and deploy its output. If both are current and the
832
+ build is fresh, it is a [bug in this package](#a-bug-in-nxgtmail-itself).
833
+
834
+ ### `createMailRenderer: <email> has no text part in <locale> — keep Maizzle's plaintext on, as @nxgt/mail-config sets it`
835
+
836
+ An `Error`.
837
+
838
+ **When:** start-up, after a build whose config turned Maizzle's plain-text
839
+ output off: `plaintext: false`, in the project's config or in a plugin that
840
+ comes after `@nxgt/mail-config`'s base.
841
+ **Why:** every e-mail is sent with a text part — `Rendered` and `MailMessage`
842
+ require one — and the renderer only fills it; it does not derive it from the
843
+ HTML at send time.
844
+ **Fix:** remove the `plaintext: false`. `defineMailConfig` sets
845
+ `plaintext: true`; keep it, then run `maizzle build` again.
846
+
847
+ ### `Could not resolve "node:fs"`, or `No such module "node:fs"`, on an edge runtime
848
+
849
+ The first as a bundler prints it (esbuild, and the tools built on it), the
850
+ second as a Workers runtime does. Other edge runtimes word it their own way.
851
+
852
+ **When:** bundling or starting code that imports `@nxgt/mail/renderer` for a
853
+ runtime without Node's `node:fs`: an edge function, a worker without a Node
854
+ compatibility mode. `@nxgt/mail` itself imports no Node built-in, so code
855
+ that only uses the `Mailer` port, the errors or a transport is not affected.
856
+ **Why:** `createMailRenderer` reads the build from a file system at start-up.
857
+ A runtime without one has no files to read, so even with a compatibility
858
+ flag that lets the import through, the renderer cannot find the build.
859
+ **Fix:** render in a runtime with a file system — Node, Bun or Deno — and
860
+ send from there. `render` is synchronous and cheap once the renderer is
861
+ created, so it belongs in the server that owns the send, not at the edge.
862
+
863
+ ---
864
+
865
+ ## Rendering
866
+
867
+ `render` refuses a call it cannot fill correctly, and **sends nothing wrong
868
+ instead**. Every message below but the URL refusal names a mistake in the calling
869
+ code or a server out of step with its build: an `Error` or a `TypeError`,
870
+ with no `code`, to be fixed rather than handled. A URL that is refused is a
871
+ `MailRefused`, `code: 'MAIL_REFUSED'`, because the URL may come from outside.
872
+ No message holds a value: a link in a verification e-mail is a credential.
873
+
874
+ ### `render: <email> is not an e-mail of the build — one of <emails>`
875
+
876
+ An `Error`. `<emails>` lists the e-mails the build wrote.
877
+
878
+ **When:** `mails.render('welcome', …)` when the build has no `welcome`:
879
+ a typo, an e-mail added to the code before its template was built, or a
880
+ server deployed with an older build. An e-mail in a subfolder is named with
881
+ its folder, as `auth/reset-password`.
882
+ **Why:** the renderer only fills what `maizzle build` wrote; the list is in
883
+ `mails.emails`.
884
+ **Fix:** use a name from the message. To catch the mismatch at start-up
885
+ rather than at a send, check the names your code uses against `mails.emails`:
886
+
887
+ ```ts
888
+ import { createMailRenderer } from '@nxgt/mail/renderer';
889
+
890
+ const mails = createMailRenderer({ dir: 'dist' });
891
+ const used = ['verify-email', 'reset-password'];
892
+ const missing = used.filter((name) => !mails.emails.includes(name));
893
+ if (missing.length > 0) {
894
+ throw new Error(`e-mails missing from the build: ${missing.join(', ')}`);
895
+ }
896
+ ```
897
+
898
+ To catch it before the code runs at all, type the renderer with the build's
899
+ `MailEmails`: see
900
+ [Rendering — typing the renderer](guide/rendering.md#typing-the-renderer).
901
+
902
+ ### `render: the locale asked for is not one of the build's, <locales>`
903
+
904
+ An `Error`.
905
+
906
+ **When:** `render(email, variables, { locale })`, with a `locale` the build
907
+ did not write: typically a stored user locale passed as is (`'fr-CA'`,
908
+ `'de'`). A locale from `getLanguage` never fails this way: it is picked with
909
+ `pickLocale`, which falls back.
910
+ **Why:** the `locale` option is taken as given — it says "this locale", not
911
+ "prefer this one".
912
+ **Fix:** let `getLanguage` answer the wanted locale, or pick one of the
913
+ build's yourself:
914
+
915
+ ```ts
916
+ import { pickLocale } from '@nxgt/mail';
917
+ import { createMailRenderer } from '@nxgt/mail/renderer';
918
+
919
+ declare const storedLocale: string | null;
920
+
921
+ const mails = createMailRenderer({ dir: 'dist' });
922
+ const locale = pickLocale(storedLocale, mails.locales, 'en');
923
+ const rendered = mails.render('sign-in-code', { code: '123456' }, { locale });
924
+ ```
925
+
926
+ ### `render: the variables of <email> must be an object, as { name: 'Ada' }`
927
+
928
+ A `TypeError`.
929
+
930
+ **When:** `render(email, variables)` with `variables` that is `null`, an
931
+ array or a string: typically the value itself passed where its object was
932
+ expected, as `render('sign-in-code', code)`.
933
+ **Why:** each placeholder is filled by name, from an object.
934
+ **Fix:** `mails.render('sign-in-code', { code })`. An e-mail with no
935
+ placeholder takes no second argument at all.
936
+
937
+ ### `render: <email> has no variable <key> — it takes <variables>`
938
+
939
+ An `Error`. `<variables>` lists every placeholder of the e-mail, or `none`.
940
+
941
+ **When:** `render`, with a key the e-mail does not have: a typo, a variable
942
+ removed from the template, or a whole object spread into the call
943
+ (`render('verify-email', { ...user, link })`).
944
+ **Why:** an unknown key is refused rather than ignored: it is a typo, a
945
+ template out of step with the code, or data you did not mean to put in an
946
+ e-mail.
947
+ **Fix:** pass exactly the placeholders the message lists:
948
+
949
+ ```ts
950
+ declare const user: { name: string; email: string };
951
+ declare const link: string;
952
+
953
+ mails.render('verify-email', { name: user.name, link });
954
+ ```
955
+
956
+ ### `render: <email> needs the variable <key>`
957
+
958
+ An `Error`.
959
+
960
+ **When:** `render`, without a value for one of the e-mail's placeholders.
961
+ The variables of an e-mail are those of **all its locales**, its subject and
962
+ its text part included, so a placeholder only the `fr` message uses is
963
+ still required when rendering in `en`.
964
+ **Why:** a placeholder left unfilled would go out as `{{ link }}`.
965
+ **Fix:** pass it. When a value is optional, decide what the e-mail says
966
+ without it in the template, and pass an empty string where that is correct:
967
+ `{ name: user.name ?? '' }`.
968
+
969
+ ### `render: <email>: <key> must be a string or a finite number`
970
+
971
+ A `TypeError`.
972
+
973
+ **When:** `render`, with a value that is `null`, `undefined`, `NaN`,
974
+ `Infinity`, a boolean, an array, a `Date` or a `URL`: typically a field read
975
+ from a database that may be missing.
976
+ **Why:** a value is written as text. A number is written as `String(n)`;
977
+ anything else would render as `null`, `undefined` or `[object Object]` in a
978
+ sent e-mail.
979
+ **Fix:** convert it yourself, in the format the recipient should read:
980
+
981
+ ```ts
982
+ declare const inviteUrl: URL;
983
+ declare const expiresAt: Date;
984
+
985
+ mails.render('invitation', {
986
+ link: inviteUrl.href,
987
+ expires: expiresAt.toLocaleDateString('fr-FR'),
988
+ });
989
+ ```
990
+
991
+ ### `render: <email>: <key> must be an http:, https: or mailto: URL`
992
+
993
+ A `MailRefused`, `code: 'MAIL_REFUSED'`.
994
+
995
+ **When:** `render`, for a placeholder that starts an `href`, `src`,
996
+ `background`, `poster` or `action` attribute in the template
997
+ (`href="{{ link }}"`), when its value is not an absolute `http:`, `https:`
998
+ or `mailto:` URL: a relative link (`/verify`, `//host`), a `javascript:` or
999
+ `data:` URL, or a URL holding whitespace, a quote, `<`, `>` or a backtick —
1000
+ typically an unencoded query value, as `?email=ada lovelace`.
1001
+ **Why:** that placeholder decides where the link leads, and a mail client
1002
+ follows it as written. The build records which placeholders sit there, in
1003
+ the manifest; escaping alone would not stop `javascript:`.
1004
+ **Fix:** pass an absolute URL, built with `URL` so every part is encoded:
1005
+
1006
+ ```ts
1007
+ declare const token: string;
1008
+
1009
+ const link = new URL('/verify', 'https://app.example');
1010
+ link.searchParams.set('token', token);
1011
+
1012
+ mails.render('verify-email', { name: 'Ada', link: link.href });
1013
+ ```
1014
+
1015
+ When the URL comes from outside your code, handle the refusal as any
1016
+ [`MAIL_REFUSED`](#mail_refused--mailrefused-the-message-was-refused-as-malformed):
1017
+ sending the same value again fails again.
1018
+
1019
+ ### A link breaks when its value holds `&`, `+`, `#` or `/`: `?token={{ token }}` is not percent-encoded
1020
+
1021
+ No error: the e-mail is sent, and the link in it is wrong.
1022
+
1023
+ **When:** a template writes a placeholder **after** the start of a URL
1024
+ attribute, as `href="https://app.example/verify?token={{ token }}"`, and the
1025
+ value holds a character that means something in a URL: a base64 token with
1026
+ `+` or `/`, a value with `&`, `#`, `?` or a space.
1027
+ **Why:** only a placeholder that **starts** the attribute is a URL variable,
1028
+ checked as a URL. One later in the value is filled like any other: escaped
1029
+ for HTML (`&` becomes `&amp;`, which a mail client reads back as `&`), never
1030
+ percent-encoded — the renderer cannot know which part of a URL it fills. The
1031
+ scheme is fixed by the template, so it is safe; the link is only wrong.
1032
+ **Fix:** encode the value yourself:
1033
+
1034
+ ```ts
1035
+ declare const token: string;
1036
+
1037
+ mails.render('verify-email', { name: 'Ada', token: encodeURIComponent(token) });
1038
+ ```
1039
+
1040
+ Or make the whole URL the placeholder (`href="{{ link }}"`) and build it with
1041
+ `URL`, as in the entry above: it is then encoded by `URL` and checked by the
1042
+ renderer.
1043
+
1044
+ ---
1045
+
1046
+ ## Conformance (transport authors)
1047
+
1048
+ These come from `@nxgt/mail/conformance`, which a transport author runs
1049
+ against their transport. A failing case throws an `Error` whose message
1050
+ starts with `conformance: `; the test title names the case id
1051
+ (`failure.outage`, `send.deliversBytes`, …).
1052
+
1053
+ ### A transport that translates its failures
1054
+
1055
+ Most failures below have the same fix: catch what the provider throws or
1056
+ answers, and throw the `@nxgt/mail` class that says what happened, with the
1057
+ provider's error as `cause`. Once, and without retrying. The same
1058
+ transport as in [the transports guide](guide/transports.md#a-transport-over-http),
1059
+ shortened:
1060
+
1061
+ ```ts
1062
+ // http-mailer.ts
1063
+ import { checkMessage, MailFailure, type Mailer, MailRefused } from '@nxgt/mail';
1064
+
1065
+ export function createHttpMailer(options: {
1066
+ readonly endpoint: string;
1067
+ readonly apiKey: string;
1068
+ /** For tests; the global fetch otherwise. */
1069
+ readonly fetch?: (url: string, init: RequestInit) => Promise<Response>;
1070
+ }): Mailer {
1071
+ const post = options.fetch ?? ((url, init) => fetch(url, init));
1072
+ return {
1073
+ async send(message) {
1074
+ checkMessage(message);
1075
+ let response: Response;
1076
+ try {
1077
+ response = await post(options.endpoint, {
1078
+ method: 'POST',
1079
+ headers: {
1080
+ authorization: `Bearer ${options.apiKey}`,
1081
+ 'content-type': 'application/json',
1082
+ },
1083
+ body: JSON.stringify(message),
1084
+ });
1085
+ } catch (cause) {
1086
+ throw new MailFailure('send: the provider could not be reached', { cause });
1087
+ }
1088
+ const cause = new Error(`the provider answered ${response.status}`);
1089
+ if (response.status === 400 || response.status === 422) {
1090
+ throw new MailRefused('send: the provider refused the message', { cause });
1091
+ }
1092
+ if (!response.ok) {
1093
+ throw new MailFailure('send: the provider did not accept the message', { cause });
1094
+ }
1095
+ const body = (await response.json()) as { id?: string };
1096
+ return { messageId: body.id ?? null };
1097
+ },
1098
+ };
1099
+ }
1100
+ ```
1101
+
1102
+ ### `describeMailer: no test runner found — pass runner: { describe, it } from your test framework`
1103
+
1104
+ A `TypeError`.
1105
+
1106
+ **When:** loading the file that calls `describeMailer` without `runner`,
1107
+ where no global `describe` and `it` exist: under `bun test`, which never puts
1108
+ them on `globalThis`; under Vitest without `globals: true`; or in a script run
1109
+ outside a test runner. Jest defines them, unless `injectGlobals` is off.
1110
+ **Why:** the suite depends on no test runner; without `runner`, it looks for
1111
+ the globals and finds none.
1112
+ **Fix:** pass them:
1113
+
1114
+ ```ts
1115
+ import { describe, it } from 'bun:test';
1116
+ import { describeMailer, referenceMailerHarness } from '@nxgt/mail/conformance';
1117
+
1118
+ describeMailer({
1119
+ name: 'my transport',
1120
+ harness: referenceMailerHarness(), // yours: see the harness below
1121
+ runner: { describe, it },
1122
+ });
1123
+ ```
1124
+
1125
+ ### `describeMailer: skip names no case: <id>`
1126
+
1127
+ A `TypeError`.
1128
+
1129
+ **When:** loading the test file, when a key of `skip` is not the id of a
1130
+ case: a typo, or a case that was renamed or removed in a newer version.
1131
+ **Why:** a skip that matches nothing would hide nothing today and silently
1132
+ hide a real case the day one gets that name.
1133
+ **Fix:** use an id from `allMailerCases`, with the reason, which is printed in
1134
+ the test title:
1135
+
1136
+ ```ts
1137
+ import { describe, it } from 'bun:test';
1138
+ import { describeMailer, referenceMailerHarness } from '@nxgt/mail/conformance';
1139
+
1140
+ describeMailer({
1141
+ name: 'my transport',
1142
+ harness: referenceMailerHarness(),
1143
+ runner: { describe, it },
1144
+ skip: { 'send.recipients': 'the sandbox delivers to one recipient only' },
1145
+ });
1146
+ ```
1147
+
1148
+ ### `faults not provided: the failure contract is not proven for this transport`
1149
+
1150
+ **When:** the `failure.*` cases. Either they fail with
1151
+ `conformance: <id>: faults not provided: … — pass faults: false to describeMailer to skip it on purpose`,
1152
+ or, with `faults: false`, they are skipped with this reason in their title.
1153
+ **Why:** the harness's `open()` answered no `faults`, so the suite cannot make
1154
+ the transport fail the way its provider fails, and the failure contract —
1155
+ *an outage throws `MailFailure`, nothing is retried* — is not proven. It is
1156
+ reported, never passed over.
1157
+ **Fix:** give the harness `faults`, driven by the fake provider the transport
1158
+ talks to in the test — not by a wrapper that throws in front of the
1159
+ transport, which would prove the wrapper. With the `fakeProvider()` of
1160
+ [the transports guide](guide/transports.md#faults--failing-the-way-the-provider-fails),
1161
+ which answers 503 for an outage and 422 for a refusal:
1162
+
1163
+ ```ts
1164
+ import type { MailerHarness } from '@nxgt/mail/conformance';
1165
+ import { fakeProvider } from './fake-provider'; // the guide's, as is
1166
+ import { createHttpMailer } from './http-mailer'; // yours
1167
+
1168
+ export const harness: MailerHarness = {
1169
+ async open() {
1170
+ const provider = fakeProvider(); // one per case, never shared
1171
+ return {
1172
+ mailer: createHttpMailer({
1173
+ endpoint: 'https://mail.example.test/send',
1174
+ apiKey: 'test',
1175
+ fetch: provider.fetch,
1176
+ }),
1177
+ delivered: async () => provider.delivered(),
1178
+ faults: provider.faults,
1179
+ };
1180
+ },
1181
+ };
1182
+ ```
1183
+
1184
+ Pass `faults: false` only for a transport whose failures truly cannot be
1185
+ simulated, so the skip is on purpose and visible.
1186
+
1187
+ ### `conformance: <send> resolved; it must reject`
1188
+
1189
+ `<send>` is, for example, `a send during an outage`, `a refused send`,
1190
+ `a send with no recipient`, `a send with a line break in the subject`,
1191
+ `a send with a Bcc header` or `a send to something that is not an address`.
1192
+
1193
+ **When:** a `failure.*` or `send.refuses*` case.
1194
+ **Why:** the transport answered where it had to throw: it caught the
1195
+ provider's error and resolved, perhaps with `{ messageId: null }`, or it did
1196
+ not call `checkMessage`. A caller then tells a user to check an inbox that
1197
+ will stay empty.
1198
+ **Fix:** call `checkMessage(message)` first thing in `send`, and let every
1199
+ failure reject, as in
1200
+ [a transport that translates its failures](#a-transport-that-translates-its-failures).
1201
+ Never catch and log.
1202
+
1203
+ ### `conformance: an outage must throw MailFailure from @nxgt/mail`
1204
+
1205
+ Also `conformance: a refusal must throw MailRefused from @nxgt/mail`.
1206
+
1207
+ **When:** `failure.outage` or `failure.refusal`.
1208
+ **Why:** the transport rejected, but not with the class from its
1209
+ `@nxgt/mail` peer: it threw the provider's error untranslated, defined its
1210
+ own class, or bundled its own copy of `@nxgt/mail`. A consumer's
1211
+ `error instanceof MailFailure` is then `false`; see
1212
+ [the entry above](#error-instanceof-mailfailure-is-false-for-an-outage).
1213
+ **Fix:** import `MailFailure` and `MailRefused` from `@nxgt/mail`, declared as
1214
+ a peer and external in the build, and throw them with the provider's error as
1215
+ `cause`.
1216
+
1217
+ ### `conformance: an outage must carry the transport's error as cause`
1218
+
1219
+ Also `conformance: a refusal must carry the transport's error as cause`.
1220
+
1221
+ **When:** `failure.outage` or `failure.refusal`.
1222
+ **Why:** the transport threw the right class without `{ cause }`. The
1223
+ provider's error is what the operator needs to find out what happened; the
1224
+ `MailError` message is only a shape.
1225
+ **Fix:**
1226
+
1227
+ ```ts
1228
+ import { MailFailure } from '@nxgt/mail';
1229
+
1230
+ try {
1231
+ await fetch('https://provider.example/send');
1232
+ } catch (cause) {
1233
+ throw new MailFailure('send: the provider could not be reached', { cause });
1234
+ }
1235
+ ```
1236
+
1237
+ ### `conformance: an outage must carry the code MAIL_FAILED`
1238
+
1239
+ Also `conformance: a refusal must carry the code MAIL_REFUSED` and
1240
+ `conformance: MailFailure must extend MailError`.
1241
+
1242
+ **When:** `failure.outage` or `failure.refusal`, after the `instanceof` check
1243
+ passed.
1244
+ **Why:** the error is an instance of the class but its `code` or prototype
1245
+ was changed — a subclass overriding `code`, or an object patched after
1246
+ construction.
1247
+ **Fix:** throw `new MailFailure(…)` or `new MailRefused(…)` as they are; do
1248
+ not subclass them.
1249
+
1250
+ ### `conformance: the transport retried a failed hand-over`
1251
+
1252
+ Also `conformance: the transport retried a refused message`.
1253
+
1254
+ **When:** `failure.outage` or `failure.refusal`: `faults.attempts()` counted
1255
+ more than one hand-over for one `send`.
1256
+ **Why:** the transport retries in secret. A retry belongs to the caller, who
1257
+ knows whether the e-mail is still worth sending; a retry inside the
1258
+ transport can deliver the same e-mail twice, and hides an outage for as long
1259
+ as it lasts.
1260
+ **Fix:** one hand-over per `send`, and throw on failure. If
1261
+ `attempts()` counts something else — a connection check, an authentication
1262
+ request — count only the hand-overs of a message.
1263
+
1264
+ ### `conformance: a name let a second recipient through`
1265
+
1266
+ **When:** `send.hostileName`: a recipient whose `name` holds an address, a
1267
+ comma and a semicolon (`'Ada <mallory@example.test>, "Eve" <eve@example.test>;'`)
1268
+ was delivered to more than its own `address`.
1269
+ **Why:** the transport pasted the display name into the `To` header
1270
+ unquoted, so the receiving server read the name as more recipients. A name is
1271
+ free text, often typed by a user; quoting it is the transport's job.
1272
+ **Fix:** hand the provider the address object and let it format the header
1273
+ (nodemailer, for one, quotes `{ name, address }` itself), or write the name
1274
+ as an RFC 5322 quoted-string:
1275
+
1276
+ ```ts
1277
+ import { type Address, addressOf } from '@nxgt/mail';
1278
+
1279
+ function formatAddress(address: Address): string {
1280
+ if (typeof address === 'string') return address;
1281
+ const name = address.name.replace(/[\\"]/g, '\\$&');
1282
+ return name === '' ? addressOf(address) : `"${name}" <${address.address}>`;
1283
+ }
1284
+ ```
1285
+
1286
+ A name outside ASCII also needs RFC 2047 encoding in a raw header; a provider
1287
+ API that takes the name as its own field does both for you.
1288
+
1289
+ ### `conformance: <what>, yet something was delivered`
1290
+
1291
+ `<what>` is `the message was refused` or `the hand-over failed`.
1292
+
1293
+ **When:** a `send.refuses*` or `failure.outage` case: the transport rejected,
1294
+ but the receiving end got the message anyway.
1295
+ **Why:** the transport handed the message over and then threw: it validated
1296
+ after sending, or it reported a failure the provider did not have. A caller
1297
+ told "not sent" retries, and the e-mail arrives twice.
1298
+ **Fix:** call `checkMessage(message)` **before** the hand-over, and throw only
1299
+ for a hand-over that did not succeed.
1300
+
1301
+ ### Other `conformance:` messages
1302
+
1303
+ Each names what the transport did not do, in the case whose id is in the
1304
+ test title:
1305
+
1306
+ | Message | Case | What to fix |
1307
+ | --- | --- | --- |
1308
+ | `conformance: send did not answer an object with messageId` | `send.answersSentMail` | resolve with `{ messageId }` |
1309
+ | `conformance: messageId must be a non-empty string or null` | `send.answersSentMail` | answer `null` when the provider gives no id, never `''` or `undefined` |
1310
+ | `conformance: expected 1 delivered message, got <n>` | `send.deliversBytes` | one `send`, one message; check `delivered()` reads a fresh receiving end per `open()` |
1311
+ | `conformance: the subject was not delivered as sent` | `send.deliversBytes` | encode the subject for non-ASCII (accents, an emoji) and do not trim it |
1312
+ | `conformance: the html part was not delivered as sent` | `send.deliversBytes` | send the HTML as is, UTF-8, no re-encoding of entities |
1313
+ | `conformance: the text part was not delivered as sent` | `send.deliversBytes` | send the text part as is, line breaks included |
1314
+ | `conformance: the recipients delivered are not the recipients sent` | `send.recipients` | deliver to every recipient, in order, with `{ name, address }` sent to `address` |
1315
+ | `conformance: a send with no recipient must throw MailRefused` | `send.refusesNoRecipient` | call `checkMessage` |
1316
+ | `conformance: a line break in the subject must throw MailRefused` | `send.refusesLineBreakInSubject` | call `checkMessage` |
1317
+ | `conformance: a malformed address must throw MailRefused` | `send.refusesWithoutTheValue` | call `checkMessage` |
1318
+ | `conformance: a Bcc header must throw MailRefused` | `send.refusesAddressHeader` | call `checkMessage`, from a version of `@nxgt/mail` that refuses reserved headers |
1319
+ | `conformance: the refusal message holds the refused value` | `send.refusesWithoutTheValue`, `send.refusesAddressHeader` | name where the problem is, never the value |
1320
+ | `conformance: the send after a failure was not delivered` | `failure.recovers` | do not leave the transport broken after a failure: reopen the connection on the next send |
1321
+ | `conformance: faults are required` | a `failure.*` case whose `run` you called yourself | pass `faults` in the context, or go through `runMailerCase`, which skips the case instead |
1322
+
1323
+ ### A bug in `@nxgt/mail` itself
1324
+
1325
+ A `conformance:` failure against `referenceMailerHarness()`, or a `send: …`
1326
+ refusal of a message this page says is valid, is a bug in this package.
1327
+ Open an issue on
1328
+ [`softistx/nxgt-mail`](https://github.com/softistx/nxgt-mail/issues) with the
1329
+ message, the package version and the smallest message or harness that
1330
+ reproduces it — with example addresses, never real ones.