@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,141 @@
1
+ # Locales
2
+
3
+ This page is for choosing the locale an e-mail is rendered in. The locale of an
4
+ e-mail is **the recipient's** — usually a field of the user — and not the
5
+ language of the request that triggered the send.
6
+
7
+ ```ts
8
+ import { pickLocale } from '@nxgt/mail';
9
+
10
+ const supported = ['en', 'fr'] as const;
11
+
12
+ pickLocale('fr-CA', supported, 'en'); // 'fr'
13
+ pickLocale(['de', 'fr', 'en'], supported, 'en'); // 'fr'
14
+ pickLocale(null, supported, 'en'); // 'en'
15
+ ```
16
+
17
+ ## `pickLocale`
18
+
19
+ ```ts
20
+ type WantedLocales = string | null | undefined | readonly (string | null | undefined)[];
21
+
22
+ function pickLocale<const L extends string>(
23
+ wanted: WantedLocales,
24
+ supported: readonly L[],
25
+ fallback: NoInfer<L>,
26
+ ): L;
27
+ ```
28
+
29
+ | Parameter | Type | Effect |
30
+ | --- | --- | --- |
31
+ | `wanted` | `WantedLocales` | The recipient's locales, most wanted first: their stored locale, then — when they are the visitor — their `Accept-Language`. `null`, `undefined` and empty strings are skipped, so a user field that may be missing goes in as is |
32
+ | `supported` | `readonly L[]` | The locales you have catalogues for. Its literal types become the answer's type |
33
+ | `fallback` | `L` | The answer when nothing wanted matches. Must be one of `supported`, which the compiler checks |
34
+
35
+ It answers one of `supported`, **spelled as in `supported`**, typed as their
36
+ union — `'en' | 'fr'` above — so it goes straight into whatever renders the
37
+ e-mail: your own function's `locale` argument, or the `locale` option of the
38
+ renderer, `mails.render(email, variables, { locale })` — see
39
+ [Rendering — choosing the locale](rendering.md#choosing-the-locale). With the
40
+ renderer, pass `mails.locales` as `supported`.
41
+
42
+ Pure: no request context, no global, no I/O.
43
+
44
+ ### How a wanted locale matches
45
+
46
+ For each wanted locale **in turn**, the first that matches wins:
47
+
48
+ 1. an exact match, ignoring case and `_` versus `-`;
49
+ 2. a match on the language alone.
50
+
51
+ | `wanted` | `supported` | Answer | Why |
52
+ | --- | --- | --- | --- |
53
+ | `'fr'` | `['en', 'fr']` | `'fr'` | exact |
54
+ | `'fr-CA'`, `'fr_CA'` | `['en', 'fr']` | `'fr'` | the language, when the region is not supported |
55
+ | `'pt'` | `['en', 'pt-BR']` | `'pt-BR'` | the only Portuguese supported |
56
+ | `'PT-br'` | `['en', 'pt-BR']` | `'pt-BR'` | case does not matter; the supported spelling is answered |
57
+ | `'fr-CA'` | `['fr', 'fr-CA']` | `'fr-CA'` | exact beats language |
58
+ | `'fr-BE'` | `['fr-CA', 'fr']` | `'fr'` | the bare language beats another region |
59
+ | `['fr-CH', 'en']` | `['en', 'fr']` | `'fr'` | the **first** wanted locale wins, even when a later one is an exact match |
60
+ | `null`, `[]`, `['', null, 'de']` | `['en', 'fr']` | the fallback | nothing wanted, or nothing matching |
61
+
62
+ ### What it refuses
63
+
64
+ A fallback outside `supported` is a compile error:
65
+
66
+ ```ts
67
+ import { pickLocale } from '@nxgt/mail';
68
+
69
+ // @ts-expect-error — 'de' is not one of supported
70
+ pickLocale('fr', ['en', 'fr'], 'de');
71
+ ```
72
+
73
+ At run time — when the list is built dynamically — the same mistakes throw a
74
+ bare `TypeError`, because they are wiring mistakes and not a request's:
75
+
76
+ | Mistake | `TypeError` message |
77
+ | --- | --- |
78
+ | `supported` is empty | `pickLocale: supported must hold at least one locale` |
79
+ | `fallback` is not in `supported` | `pickLocale: fallback must be one of supported` |
80
+
81
+ ## `parseAcceptLanguage`
82
+
83
+ ```ts
84
+ function parseAcceptLanguage(header: string | null | undefined): string[];
85
+ ```
86
+
87
+ The locales of an `Accept-Language` header, most wanted first: ordered by their
88
+ `q` weight, ties keeping the header's order. `q=0` entries, `*` and empty
89
+ entries are dropped; a missing or empty header answers `[]`.
90
+
91
+ ```ts
92
+ import { parseAcceptLanguage } from '@nxgt/mail';
93
+
94
+ parseAcceptLanguage('fr-CA,fr;q=0.9,en;q=0.8'); // ['fr-CA', 'fr', 'en']
95
+ parseAcceptLanguage('en;q=0.5, de, fr;q=0.5'); // ['de', 'en', 'fr']
96
+ parseAcceptLanguage('fr;q=0, *, , en'); // ['en']
97
+ parseAcceptLanguage(null); // []
98
+ ```
99
+
100
+ Its answer is meant to be spread into `pickLocale`'s `wanted`, after the
101
+ recipient's stored locale.
102
+
103
+ ## Whose locale
104
+
105
+ Use the request's `Accept-Language` **only when the recipient is the visitor**
106
+ — signing up, asking for a password reset — and only after any locale you have
107
+ stored for them. When someone else triggers the send — an administrator
108
+ inviting a user, a job sending a reminder — the request's language is the wrong
109
+ person's.
110
+
111
+ ```ts
112
+ import { type Mailer, parseAcceptLanguage, pickLocale } from '@nxgt/mail';
113
+ import { createMailRenderer } from '@nxgt/mail/renderer';
114
+
115
+ // The locales the build was made in, as a tuple for the Locale type; at run
116
+ // time they are also mails.locales.
117
+ const supported = ['en', 'fr'] as const;
118
+ type Locale = (typeof supported)[number];
119
+
120
+ const mails = createMailRenderer({ dir: 'dist' });
121
+
122
+ // The visitor is the recipient: their stored locale, then their browser's.
123
+ export function localeOfVisitor(request: Request, stored: string | null): Locale {
124
+ return pickLocale([stored, ...parseAcceptLanguage(request.headers.get('accept-language'))], supported, 'en');
125
+ }
126
+
127
+ // Someone else is the recipient: their locale only, never the request's.
128
+ export async function invite(
129
+ mailer: Mailer,
130
+ invitee: { email: string; locale: string | null },
131
+ link: string,
132
+ ): Promise<void> {
133
+ const locale = pickLocale(invitee.locale, supported, 'en');
134
+ await mailer.send({ to: invitee.email, ...mails.render('invitation', { link }, { locale }) });
135
+ }
136
+ ```
137
+
138
+ ## See also
139
+
140
+ - [Rendering](rendering.md) — the renderer's `getLanguage` and `{ locale }`.
141
+ - [Sending](sending.md) — what to do with the rendered e-mail next.
@@ -0,0 +1,523 @@
1
+ # Rendering
2
+
3
+ This page is for turning a Maizzle build made with `@nxgt/mail-i18n` into an
4
+ e-mail ready to send: `createMailRenderer` reads the built files once, and
5
+ `render` fills the values only known at send time, escaped, in the locale
6
+ wanted.
7
+
8
+ ```ts
9
+ import { createMemoryMailer } from '@nxgt/mail';
10
+ import { createMailRenderer } from '@nxgt/mail/renderer';
11
+
12
+ const mails = createMailRenderer({ dir: 'dist' }); // the folder `maizzle build` wrote
13
+ const mailer = createMemoryMailer(); // in production, a transport's mailer
14
+
15
+ await mailer.send({
16
+ to: 'ada@example.com',
17
+ ...mails.render('verify-email', {
18
+ name: 'Ada',
19
+ link: 'https://app.example.com/verify?token=abc',
20
+ }),
21
+ });
22
+ ```
23
+
24
+ `render` answers `{ subject, html, text }` — a `Rendered` — which is spread
25
+ into the `MailMessage` and addressed. See [Sending](sending.md) for the rest
26
+ of the message.
27
+
28
+ ## What it reads
29
+
30
+ `maizzle build`, with the `i18n()` plugin of `@nxgt/mail-i18n`, writes one
31
+ HTML and one text file per e-mail and locale, and `mail-manifest.json` beside
32
+ them:
33
+
34
+ ```text
35
+ dist/
36
+ mail-manifest.json
37
+ en/verify-email.html
38
+ en/verify-email.txt
39
+ fr/verify-email.html
40
+ fr/verify-email.txt
41
+ ```
42
+
43
+ The manifest lists each e-mail's variables, the ones that must be URLs, its
44
+ subject in each locale and its files. The renderer reads nothing else. Its
45
+ shape is described in `@nxgt/mail-i18n`'s
46
+ [manifest guide](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-i18n/docs/guide/manifest.md).
47
+
48
+ Nothing is evaluated at send time: no template engine and no Maizzle run in
49
+ your server. `render` replaces each `{{ name }}` the build left in place, and
50
+ nothing else.
51
+
52
+ ## `createMailRenderer`
53
+
54
+ ```ts
55
+ type WantedLocales = string | null | undefined | readonly (string | null | undefined)[];
56
+
57
+ interface MailRendererOptions {
58
+ readonly dir: string;
59
+ readonly getLanguage?: () => WantedLocales;
60
+ readonly fallbackLocale?: string;
61
+ }
62
+
63
+ type MailEmailsOf<E> = { readonly [K in keyof E]: MailVariables };
64
+ type AnyMailEmails = Readonly<Record<string, MailVariables>>;
65
+ type RenderArguments<V> =
66
+ Readonly<Record<string, never>> extends V
67
+ ? [variables?: V, options?: RenderOptions]
68
+ : [variables: V, options?: RenderOptions];
69
+
70
+ interface MailRenderer<E extends MailEmailsOf<E> = AnyMailEmails> {
71
+ readonly emails: readonly (keyof E & string)[];
72
+ readonly locales: readonly string[];
73
+ render<N extends keyof E & string>(email: N, ...rest: RenderArguments<E[N]>): Rendered;
74
+ }
75
+
76
+ function createMailRenderer<E extends MailEmailsOf<E> = AnyMailEmails>(
77
+ options: MailRendererOptions,
78
+ ): MailRenderer<E>;
79
+ ```
80
+
81
+ `E` is optional: left out, it is `AnyMailEmails`, and `render` takes any
82
+ name and `MailVariables`, as `render(email: string, variables?:
83
+ MailVariables, options?: RenderOptions)`. Given the build's `MailEmails`, it
84
+ checks the names and the variables at compile time: see
85
+ [Typing the renderer](#typing-the-renderer).
86
+
87
+ | Option | Type | Default | Effect |
88
+ | --- | --- | --- | --- |
89
+ | `dir` | `string` | required | The build's output folder, where `mail-manifest.json` is. A relative path is read from the process's working directory |
90
+ | `getLanguage` | `() => WantedLocales` | none: the fallback locale | Asked **at each render** for the wanted locales, most wanted first, then matched against the build's locales with [`pickLocale`](locales.md) |
91
+ | `fallbackLocale` | `string` | the manifest's `fallbackLocale` | The locale when nothing wanted is built. Must be one of the build's locales |
92
+
93
+ The renderer it answers is frozen:
94
+
95
+ ```ts
96
+ import { createMailRenderer } from '@nxgt/mail/renderer';
97
+
98
+ const mails = createMailRenderer({ dir: 'dist' });
99
+
100
+ mails.emails; // ['sign-in-code', 'verify-email'] — sorted
101
+ mails.locales; // ['en', 'fr'] — in the manifest's order
102
+ ```
103
+
104
+ ### Everything is read once, at creation
105
+
106
+ The manifest and every file it lists are read when `createMailRenderer` is
107
+ called, and kept in memory. A missing or broken build **throws there**, when
108
+ your server starts, not at the first send an hour later. Create one renderer
109
+ at start-up and share it; creating one per request reads the whole build each
110
+ time.
111
+
112
+ ```ts
113
+ import { createMailRenderer } from '@nxgt/mail/renderer';
114
+
115
+ // At start-up: a missing build stops the process here.
116
+ export const mails = createMailRenderer({ dir: process.env.MAIL_DIR ?? 'dist' });
117
+ ```
118
+
119
+ A rebuild is not picked up by a running renderer: create a new one, or
120
+ restart.
121
+
122
+ ## `render`
123
+
124
+ ```ts
125
+ type MailVariables = Readonly<Record<string, string | number>>;
126
+
127
+ interface RenderOptions {
128
+ readonly locale?: string;
129
+ }
130
+
131
+ interface Rendered {
132
+ readonly subject: string;
133
+ readonly html: string;
134
+ readonly text: string;
135
+ }
136
+ ```
137
+
138
+ `render(email, variables, options)` answers the e-mail named `email` — its
139
+ template's path under `emails/`, without `.vue`, as `verify-email` or
140
+ `auth/reset-password` — with every variable filled:
141
+
142
+ ```ts
143
+ import { createMailRenderer } from '@nxgt/mail/renderer';
144
+
145
+ const mails = createMailRenderer({ dir: 'dist' });
146
+
147
+ mails.render('sign-in-code', { code: 123456 }, { locale: 'fr' });
148
+ // {
149
+ // subject: 'Votre code de connexion : 123456',
150
+ // html: '<!DOCTYPE html>\n<html lang="fr"><body><p>123456</p></body></html>\n',
151
+ // text: 'Votre code : 123456\n',
152
+ // }
153
+ ```
154
+
155
+ - **Every variable of the manifest is required**, and no other is accepted:
156
+ a missing one or an unknown one throws. `variables` defaults to `{}`, for an
157
+ e-mail that takes none.
158
+ - **A value is a string or a finite number.** A number is written as
159
+ `String(n)`; `NaN`, `Infinity`, `null`, `undefined`, an object, an array or
160
+ a `URL` are refused, so nothing renders as `[object Object]`. Pass
161
+ `url.href` for a `URL`.
162
+ - **The names are checked at run time**, against the manifest, typed or not.
163
+ Untyped, the compiler checks that each value is a string or a number, not
164
+ that `verify-email` takes `link`; given the build's `MailEmails`, it checks
165
+ that too — see [Typing the renderer](#typing-the-renderer).
166
+
167
+ ## Typing the renderer
168
+
169
+ After each `maizzle build`, `@nxgt/mail-i18n` writes `generated/mail.ts` in
170
+ the project: `MailEmails`, each e-mail of the build with the variables it
171
+ takes. Commit it, so the code that sends type-checks without running a build,
172
+ and pass it to `createMailRenderer`:
173
+
174
+ ```ts
175
+ // generated/mail.ts — written by the build, never edited
176
+ export interface MailEmails {
177
+ "auth/reset-password": { readonly email: string | number; readonly resetLink: string };
178
+ "sign-in-code": { readonly code: string | number };
179
+ "verify-email": { readonly link: string; readonly name: string | number };
180
+ "welcome": Readonly<Record<string, never>>;
181
+ }
182
+ ```
183
+
184
+ ```ts
185
+ import { createMailRenderer } from '@nxgt/mail/renderer';
186
+ import type { MailEmails } from './generated/mail';
187
+
188
+ const mails = createMailRenderer<MailEmails>({ dir: 'dist' });
189
+
190
+ mails.render('verify-email', { name: 'Ada', link: 'https://app.example.com/verify?token=abc' });
191
+ mails.render('sign-in-code', { code: 123456 }, { locale: 'fr' }); // a number, where it is not a URL
192
+ mails.render('welcome'); // an e-mail that takes no variable needs no variables argument
193
+ mails.emails; // typed readonly ('auth/reset-password' | 'sign-in-code' | 'verify-email' | 'welcome')[]
194
+ ```
195
+
196
+ A URL variable (one of the manifest's `urlVariables`) is `string`; any other
197
+ variable is `string | number`, as `render` writes it. How the file is
198
+ written, where, and how to turn it off is in `@nxgt/mail-i18n`'s
199
+ [manifest guide](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-i18n/docs/guide/manifest.md#the-renderers-types--generatedmailts).
200
+
201
+ ### What it refuses
202
+
203
+ Each is what `render` would throw at run time, moved to the compiler, with the
204
+ message `tsc` prints:
205
+
206
+ | Call | Compile error |
207
+ | --- | --- |
208
+ | `mails.render('verify-emial', { name, link })` | `TS2345: Argument of type '"verify-emial"' is not assignable to parameter of type '"auth/reset-password" \| "sign-in-code" \| "verify-email" \| "welcome"'.` |
209
+ | `mails.render('sign-in-code', { code: 1, name: 'Ada' })` | `TS2353: Object literal may only specify known properties, and 'name' does not exist in type '{ readonly code: string \| number; }'.` |
210
+ | `mails.render('verify-email', { link })` | `TS2345: … Property 'name' is missing in type '{ link: string; }' but required in type '{ readonly link: string; readonly name: string \| number; }'.` |
211
+ | `mails.render('sign-in-code')` | `TS2554: Expected 2-3 arguments, but got 1.` |
212
+ | `mails.render('verify-email', { link: 42, name: 'Ada' })` | `TS2322: Type 'number' is not assignable to type 'string'.` |
213
+
214
+ The compiler checks a name written as a literal, with its variables written as
215
+ an object literal at the call. Two calls still compile, and throw at the send:
216
+
217
+ - **Variables built beforehand** with a property too many:
218
+ `const variables = { code: 1, name: 'Ada' }; mails.render('sign-in-code', variables)`
219
+ — TypeScript flags an unknown property only in an object literal.
220
+ - **A name typed as a union**, as `'sign-in-code' | 'verify-email'`: its
221
+ variables are checked against one of the e-mails, not all of them.
222
+
223
+ A hand-written `MailEmails` writes each e-mail's variables as a type literal,
224
+ as the generated file does: an `interface` has no index signature, and
225
+ `createMailRenderer` refuses it.
226
+
227
+ The run-time checks are unchanged: a renderer typed with a `MailEmails` older
228
+ than the deployed build still throws on a name or a variable the build does
229
+ not have, and `mails.emails` may hold a name `MailEmails` does not. When the
230
+ build changes, the next `maizzle build` rewrites
231
+ `generated/mail.ts`, and the compiler points at every call it breaks.
232
+
233
+ ### Untyped
234
+
235
+ Without the type parameter, `E` is `AnyMailEmails`: every name compiles, and
236
+ `variables` is `MailVariables`. That is the choice for variables built at run
237
+ time, as a `Record<string, string>` read from a queue — the typed `render`
238
+ refuses it — and for a renderer over a build this code does not know.
239
+
240
+ ```ts
241
+ import { createMailRenderer, type MailVariables } from '@nxgt/mail/renderer';
242
+
243
+ const mails = createMailRenderer({ dir: 'dist' });
244
+
245
+ declare const job: { email: string; variables: MailVariables };
246
+ mails.render(job.email, job.variables); // checked at run time only
247
+ ```
248
+
249
+ A `MailRenderer<MailEmails>` is accepted where a `MailRenderer` is expected,
250
+ so a helper written for any build takes a typed renderer.
251
+
252
+ The manifest guide also shows
253
+ [a test that holds the two sides together](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-i18n/docs/guide/manifest.md#checking-your-application-against-it),
254
+ for a project that does not commit `generated/mail.ts`.
255
+
256
+ ## Choosing the locale
257
+
258
+ Each render picks its locale in this order:
259
+
260
+ 1. `options.locale`, when given — used **as is**: it must be spelled exactly
261
+ as one of `mails.locales`, or `render` throws.
262
+ 2. Otherwise, `pickLocale(getLanguage(), mails.locales, fallbackLocale)`:
263
+ the first wanted locale the build has, matched on the language when the
264
+ region is not built (`fr-CA` renders `fr`), else the fallback locale.
265
+ 3. Without `getLanguage`, the fallback locale.
266
+
267
+ The locale of an e-mail is the **recipient's**, not the request's (see
268
+ [Whose locale](locales.md#whose-locale)). The plainest way to say whose is to
269
+ pick it where you know the recipient, with `pickLocale`, and pass it:
270
+
271
+ ```ts
272
+ import { pickLocale } from '@nxgt/mail';
273
+ import { createMailRenderer } from '@nxgt/mail/renderer';
274
+
275
+ const mails = createMailRenderer({ dir: 'dist' });
276
+
277
+ declare const user: { email: string; name: string; locale: string | null };
278
+
279
+ const locale = pickLocale(user.locale, mails.locales, 'en'); // 'fr' for 'fr-CA'
280
+ const rendered = mails.render('verify-email', { name: user.name, link: 'https://app.example.com/v' }, { locale });
281
+ ```
282
+
283
+ `getLanguage` is for code that already carries the recipient's locale in a
284
+ context — as `@nxgt/i18n`'s language provider does. It is asked at each
285
+ render, so it reads the context of the current call:
286
+
287
+ ```ts
288
+ import { AsyncLocalStorage } from 'node:async_hooks';
289
+ import { createMailRenderer } from '@nxgt/mail/renderer';
290
+
291
+ const recipient = new AsyncLocalStorage<{ locale: string | null }>();
292
+
293
+ const mails = createMailRenderer({
294
+ dir: 'dist',
295
+ getLanguage: () => recipient.getStore()?.locale, // undefined: the fallback locale
296
+ fallbackLocale: 'en',
297
+ });
298
+
299
+ recipient.run({ locale: 'fr-CA' }, () => mails.render('sign-in-code', { code: 1 })); // in fr
300
+ ```
301
+
302
+ `getLanguage` may answer a string, `null`, `undefined`, or a list such as
303
+ `[user.locale, ...parseAcceptLanguage(header)]`. What it answers never throws:
304
+ a locale the build lacks falls back. Only `options.locale` can name a locale
305
+ that does not exist.
306
+
307
+ ## Escaping
308
+
309
+ A value is data, never markup and never a header. Where it lands decides what
310
+ is done to it:
311
+
312
+ | Part | What happens to a value |
313
+ | --- | --- |
314
+ | `html` | HTML-escaped: `&` `<` `>` `"` `'` become `&amp;` `&lt;` `&gt;` `&quot;` `&#39;`. Safe in text and in a quoted attribute |
315
+ | `text` | Written as is: a plain-text part has no markup to escape |
316
+ | `subject` | Written as is, then each run of line breaks becomes one space, and the subject is trimmed |
317
+
318
+ ```ts
319
+ import { createMailRenderer } from '@nxgt/mail/renderer';
320
+
321
+ const mails = createMailRenderer({ dir: 'dist' });
322
+ const name = '<script>alert("x")</script> & \'Ada\'';
323
+
324
+ const { html, text } = mails.render('verify-email', { name, link: 'https://app.example.com/v' });
325
+ // html: <p>Hello &lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt; &amp; &#39;Ada&#39;,</p>
326
+ // text: Hello <script>alert("x")</script> & 'Ada',
327
+ ```
328
+
329
+ Each placeholder is replaced in one pass, so a value is never read again: a
330
+ value holding `{{ link }}` is written as `{{ link }}`, not as the link.
331
+
332
+ ### URL variables
333
+
334
+ A variable that starts an `href`, a `src` or another URL attribute in the
335
+ build is a **URL variable** (`urlVariables` in the manifest). Its value must
336
+ be a URL a mail client cannot run:
337
+
338
+ - it starts with `http://`, `https://` or `mailto:`, in any case;
339
+ - it holds no whitespace, no control character, no quote, no `<`, `>` or
340
+ backtick;
341
+ - it parses as a URL.
342
+
343
+ Anything else throws `MailRefused`, before the e-mail is sent:
344
+
345
+ | Value of `link` | Answer |
346
+ | --- | --- |
347
+ | `https://app.example.com/verify?token=abc&next=%2F` | accepted; `&` is written `&amp;` in `html` |
348
+ | `mailto:ada@example.com` | accepted |
349
+ | `javascript:alert(1)`, `JaVaScRiPt:alert(1)`, ` javascript:alert(1)` | `MailRefused` |
350
+ | `data:text/html,…` | `MailRefused` |
351
+ | `//evil.example`, `/relative` | `MailRefused`: a URL in an e-mail is absolute |
352
+ | `https://app.example.com/"onmouseover="alert(1)` | `MailRefused`: a quote |
353
+ | `https://app.example.com/` followed by a line break | `MailRefused` |
354
+ | `https://` | `MailRefused`: does not parse |
355
+
356
+ `mailto:` is accepted in **every** URL variable, `src` included: the manifest
357
+ records which variables start a URL attribute, not which attribute. A
358
+ `mailto:` image source loads nothing, which is harmless, so a template that
359
+ takes an image URL from a variable should expect `https:`, and the code that
360
+ sends it should pass one.
361
+
362
+ A variable later in the attribute — `href="https://app.example.com/verify?token={{ token }}"`
363
+ — is not a URL variable: the scheme is already written, so any string is
364
+ accepted, HTML-escaped. Encode it for a URL yourself
365
+ (`encodeURIComponent(token)`) when it may hold `&`, `#` or `?`.
366
+
367
+ ### The subject
368
+
369
+ The subject is a header. Each run of line breaks in it — `\r`, `\n`, a
370
+ vertical tab, a form feed, `U+0085`, `U+2028`, `U+2029` — becomes **one
371
+ space**, and the result is trimmed, so a value cannot add a header:
372
+
373
+ ```ts
374
+ import { createMailRenderer } from '@nxgt/mail/renderer';
375
+
376
+ const mails = createMailRenderer({ dir: 'dist' });
377
+
378
+ mails.render('verify-email', {
379
+ name: 'Ada\r\nBcc: victim@example.com',
380
+ link: 'https://app.example.com/v',
381
+ }).subject;
382
+ // 'Confirm your address, Ada Bcc: victim@example.com'
383
+ ```
384
+
385
+ ## Errors
386
+
387
+ Every message names the e-mail, the locale or the variable — **never a
388
+ value**: a link in a verification e-mail is a credential.
389
+
390
+ ### When the renderer is created
391
+
392
+ A mistake in the options is a bare `TypeError`; a build that is missing or
393
+ broken is an `Error`, the file system's error as `cause` when a file cannot be
394
+ read. The paths are `dir` joined with the file, as you passed `dir`.
395
+
396
+ | Message | Class | Cause |
397
+ | --- | --- | --- |
398
+ | `createMailRenderer: options must be an object, as { dir: 'dist' }` | `TypeError` | No options object |
399
+ | `createMailRenderer: dir must be the folder maizzle build wrote, as dist` | `TypeError` | `dir` missing, empty or not a string |
400
+ | `createMailRenderer: getLanguage must be a function that answers the wanted locales, as () => user.locale` | `TypeError` | `getLanguage` given as a locale rather than a function |
401
+ | `createMailRenderer: dist/mail-manifest.json cannot be read — run maizzle build, and deploy its output folder` | `Error` | No build at `dir`: not built, not deployed, or `dir` read from another working directory |
402
+ | `createMailRenderer: dist/mail-manifest.json is not valid JSON` | `Error` | The manifest was cut or edited |
403
+ | `createMailRenderer: dist/mail-manifest.json is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin` | `Error` | A JSON file without `locales`, `fallbackLocale` and `emails` |
404
+ | `createMailRenderer: fallbackLocale must be one of the build's locales, en, fr` | `TypeError` | `fallbackLocale` names a locale the build does not have |
405
+ | `createMailRenderer: mail-manifest.json describes verify-email in a shape this version does not read — rebuild with the same version of @nxgt/mail-i18n` | `Error` | An entry of the manifest lacks a field, or a locale — usually a build made with another version |
406
+ | `createMailRenderer: verify-email has no text part in fr — keep Maizzle's plaintext on, as @nxgt/mail-config sets it` | `Error` | The project turned Maizzle's `plaintext` off; every e-mail sent has a text part |
407
+ | `createMailRenderer: dist/fr/verify-email.txt cannot be read — run maizzle build, and deploy its output folder` | `Error` | A file the manifest lists is gone: only part of the build was deployed |
408
+
409
+ ### When an e-mail is rendered
410
+
411
+ Checked in this order: the e-mail, the locale, then the variables.
412
+
413
+ | Message | Class | Cause |
414
+ | --- | --- | --- |
415
+ | `render: welcome is not an e-mail of the build — one of sign-in-code, verify-email` | `Error` | No template `emails/welcome.vue` was built |
416
+ | `render: the locale asked for is not one of the build's, en, fr` | `Error` | `{ locale: 'de' }`, or a spelling the build does not use (`fr-CA`, `FR`). Pick it with `pickLocale` |
417
+ | `render: the variables of sign-in-code must be an object, as { name: 'Ada' }` | `TypeError` | `variables` is not a plain object |
418
+ | `render: sign-in-code has no variable name — it takes code` | `Error` | A variable the e-mail does not take (`— it takes none` for an e-mail without variables) |
419
+ | `render: sign-in-code: code must be a string or a finite number` | `TypeError` | `null`, `undefined`, `NaN`, an object, an array |
420
+ | `render: verify-email needs the variable link` | `Error` | A variable the e-mail takes was not passed |
421
+ | `render: verify-email: link must be an http:, https: or mailto: URL` | `MailRefused` | A URL variable holding anything else — see [URL variables](#url-variables) |
422
+
423
+ Every one but the last is a mistake in the code: it fails the same way for
424
+ every recipient. `MailRefused` carries `code: 'MAIL_REFUSED'`, like a
425
+ malformed message refused by `send`, so a handler that turns a `MailError`
426
+ into a response covers it when `render` is inside the same `try`
427
+ ([Sending — errors](sending.md#errors)).
428
+
429
+ ## Deploying
430
+
431
+ - **Ship the build's output folder with your server.** `dist/`, with
432
+ `mail-manifest.json` and every locale folder, is read at run time: build it
433
+ in CI and copy it into the image, or publish it in a package the server
434
+ depends on.
435
+ - **Point `dir` at it independently of the working directory** when the
436
+ process may start elsewhere:
437
+
438
+ ```ts
439
+ import { fileURLToPath } from 'node:url';
440
+ import { createMailRenderer } from '@nxgt/mail/renderer';
441
+
442
+ const mails = createMailRenderer({
443
+ dir: fileURLToPath(new URL('../mails/dist', import.meta.url)),
444
+ });
445
+ ```
446
+
447
+ - **The renderer needs a file system.** It imports `node:fs` and `node:path`,
448
+ so it runs on Node, Bun and Deno — not on an edge runtime without `fs`.
449
+ That is why it is its own entry, `@nxgt/mail/renderer`: `@nxgt/mail` and
450
+ `@nxgt/mail/conformance` import no Node built-in, so the port, the errors
451
+ and a transport run anywhere.
452
+
453
+ ## A realistic case — the account e-mails of a service
454
+
455
+ One renderer, created at start-up, and a mailer, passed in so a test can use
456
+ the memory mailer:
457
+
458
+ ```ts
459
+ import { MailError, type Mailer, pickLocale } from '@nxgt/mail';
460
+ import { type MailRenderer } from '@nxgt/mail/renderer';
461
+ import type { MailEmails } from './generated/mail';
462
+
463
+ interface User {
464
+ readonly email: string;
465
+ readonly name: string;
466
+ readonly locale: string | null;
467
+ }
468
+
469
+ export class AccountMails {
470
+ constructor(
471
+ private readonly mailer: Mailer,
472
+ private readonly mails: MailRenderer<MailEmails>, // a renamed variable fails tsc here
473
+ ) {}
474
+
475
+ /** true when the e-mail left; false when it failed or was refused. */
476
+ async sendVerification(user: User, token: string): Promise<boolean> {
477
+ const link = `https://app.example.com/verify?token=${encodeURIComponent(token)}`;
478
+ const locale = pickLocale(user.locale, this.mails.locales, 'en');
479
+ try {
480
+ await this.mailer.send({
481
+ to: { name: user.name, address: user.email },
482
+ ...this.mails.render('verify-email', { name: user.name, link }, { locale }),
483
+ });
484
+ return true;
485
+ } catch (error) {
486
+ if (!(error instanceof MailError)) throw error; // a mistake in the code: let it fail loudly
487
+ return false; // MAIL_FAILED: offer to send it again; MAIL_REFUSED: the address or the link is unusable
488
+ }
489
+ }
490
+ }
491
+ ```
492
+
493
+ Its test renders the real build and reads the outbox:
494
+
495
+ ```ts
496
+ import { expect, it } from 'bun:test';
497
+ import { createMemoryMailer } from '@nxgt/mail';
498
+ import { createMailRenderer } from '@nxgt/mail/renderer';
499
+ import { AccountMails } from './account-mails';
500
+ import type { MailEmails } from './generated/mail';
501
+
502
+ const mails = createMailRenderer<MailEmails>({ dir: 'dist' });
503
+
504
+ it("sends the verification e-mail in the recipient's locale", async () => {
505
+ const mailer = createMemoryMailer();
506
+ const accounts = new AccountMails(mailer, mails);
507
+
508
+ expect(await accounts.sendVerification({ email: 'ada@example.com', name: 'Ada', locale: 'fr-CA' }, 'abc')).toBe(true);
509
+
510
+ const [sent] = mailer.sent;
511
+ expect(sent?.subject).toBe('Confirmez votre adresse, Ada');
512
+ expect(sent?.text).toContain('https://app.example.com/verify?token=abc');
513
+ });
514
+ ```
515
+
516
+ ## See also
517
+
518
+ - [Sending](sending.md) — the `MailMessage` the rendered e-mail is spread into,
519
+ and the errors of `send`.
520
+ - [Locales](locales.md) — `pickLocale` and `parseAcceptLanguage`.
521
+ - [Testing](testing.md) — the memory mailer and its outbox.
522
+ - [Troubleshooting](../troubleshooting.md) — an error message, its cause and
523
+ its fix.