@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,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 `&` `<` `>` `"` `'`. 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 <script>alert("x")</script> & 'Ada',</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 `&` 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.
|