@nxgt/mail 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/dist/chunks/index-0f7kdb8k.js +114 -0
- package/dist/chunks/index-0f7kdb8k.js.map +11 -0
- package/dist/chunks/index-vq4e9n8f.js +39 -0
- package/dist/chunks/index-vq4e9n8f.js.map +10 -0
- package/dist/chunks/index-we4n5yfz.js +28 -0
- package/dist/chunks/index-we4n5yfz.js.map +10 -0
- package/dist/conformance/assert.d.ts +12 -0
- package/dist/conformance/assert.d.ts.map +1 -0
- package/dist/conformance/cases/failure.d.ts +4 -0
- package/dist/conformance/cases/failure.d.ts.map +1 -0
- package/dist/conformance/cases/index.d.ts +6 -0
- package/dist/conformance/cases/index.d.ts.map +1 -0
- package/dist/conformance/cases/send.d.ts +4 -0
- package/dist/conformance/cases/send.d.ts.map +1 -0
- package/dist/conformance/describe.d.ts +37 -0
- package/dist/conformance/describe.d.ts.map +1 -0
- package/dist/conformance/index.d.ts +20 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +297 -0
- package/dist/conformance/index.js.map +16 -0
- package/dist/conformance/reference.d.ts +7 -0
- package/dist/conformance/reference.d.ts.map +1 -0
- package/dist/conformance/sample.d.ts +4 -0
- package/dist/conformance/sample.d.ts.map +1 -0
- package/dist/conformance/types.d.ts +74 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/errors.d.ts +68 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +9 -0
- package/dist/locale.d.ts +33 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/memory.d.ts +34 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/message.d.ts +23 -0
- package/dist/message.d.ts.map +1 -0
- package/dist/renderer.d.ts +80 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +169 -0
- package/dist/renderer.js.map +10 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.d.ts.map +1 -0
- package/docs/README.md +16 -0
- package/docs/guide/locales.md +141 -0
- package/docs/guide/rendering.md +523 -0
- package/docs/guide/sending.md +325 -0
- package/docs/guide/testing.md +175 -0
- package/docs/guide/transports.md +451 -0
- package/docs/roadmap.md +112 -0
- package/docs/troubleshooting.md +1330 -0
- package/package.json +68 -0
|
@@ -0,0 +1,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 `&`, 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.
|