@nxgt/janus-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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +262 -0
  3. package/dist/generated/locales.d.ts +5 -0
  4. package/dist/generated/locales.d.ts.map +1 -0
  5. package/dist/generated/mail.d.ts +29 -0
  6. package/dist/generated/mail.d.ts.map +1 -0
  7. package/dist/index.d.ts +20 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +234 -0
  10. package/dist/index.js.map +14 -0
  11. package/dist/janus-mail.d.ts +24 -0
  12. package/dist/janus-mail.d.ts.map +1 -0
  13. package/dist/mails.d.ts +26 -0
  14. package/dist/mails.d.ts.map +1 -0
  15. package/dist/options.d.ts +27 -0
  16. package/dist/options.d.ts.map +1 -0
  17. package/dist/templates.d.ts +15 -0
  18. package/dist/templates.d.ts.map +1 -0
  19. package/dist/types.d.ts +145 -0
  20. package/dist/types.d.ts.map +1 -0
  21. package/docs/README.md +34 -0
  22. package/docs/guide/building.md +130 -0
  23. package/docs/guide/locales.md +113 -0
  24. package/docs/guide/sending.md +242 -0
  25. package/docs/guide/templates.md +183 -0
  26. package/docs/roadmap.md +52 -0
  27. package/docs/troubleshooting.md +313 -0
  28. package/mails/en/email-changed.html +42 -0
  29. package/mails/en/email-changed.txt +21 -0
  30. package/mails/en/password-changed.html +42 -0
  31. package/mails/en/password-changed.txt +21 -0
  32. package/mails/en/reset-password.html +40 -0
  33. package/mails/en/reset-password.txt +22 -0
  34. package/mails/en/sign-in-code.html +36 -0
  35. package/mails/en/sign-in-code.txt +15 -0
  36. package/mails/en/verify-email.html +40 -0
  37. package/mails/en/verify-email.txt +22 -0
  38. package/mails/fr/email-changed.html +42 -0
  39. package/mails/fr/email-changed.txt +21 -0
  40. package/mails/fr/password-changed.html +41 -0
  41. package/mails/fr/password-changed.txt +21 -0
  42. package/mails/fr/reset-password.html +39 -0
  43. package/mails/fr/reset-password.txt +22 -0
  44. package/mails/fr/sign-in-code.html +36 -0
  45. package/mails/fr/sign-in-code.txt +15 -0
  46. package/mails/fr/verify-email.html +40 -0
  47. package/mails/fr/verify-email.txt +22 -0
  48. package/mails/mail-manifest.json +128 -0
  49. package/package.json +71 -0
@@ -0,0 +1,242 @@
1
+ # Sending
2
+
3
+ This page is for wiring `janusMail()` into an application: what it takes,
4
+ what each method sends and to whom, what happens when a send fails, and how
5
+ to test it.
6
+
7
+ ```ts
8
+ import { janus, createMemoryStores, scryptHasher } from '@nxgt/janus';
9
+ import { janusMail } from '@nxgt/janus-mail';
10
+ import { createSmtpMailer } from '@nxgt/mail-smtp'; // or any @nxgt/mail transport
11
+ import { z } from 'zod';
12
+
13
+ export const auth = janus({
14
+ user: z.object({ email: z.email(), name: z.string(), locale: z.string().nullable() }),
15
+ password: { login: 'email' },
16
+ store: createMemoryStores(),
17
+ hasher: scryptHasher(),
18
+ });
19
+
20
+ export const mail = janusMail({
21
+ mailer: createSmtpMailer({ transporter }), // transporter: nodemailer.createTransport(…)
22
+ from: { name: 'Acme', address: 'noreply@acme.example' },
23
+ replyTo: 'support@acme.example',
24
+ brand: 'Acme',
25
+ links: {
26
+ verifyEmail: (token) => `https://acme.example/verify?token=${encodeURIComponent(token)}`,
27
+ resetPassword: (token) => `https://acme.example/reset?token=${encodeURIComponent(token)}`,
28
+ secureAccount: () => 'https://acme.example/account/security',
29
+ },
30
+ });
31
+ ```
32
+
33
+ Create it once, at start-up, beside `janus()`. It reads nothing when called:
34
+ the default e-mails are read from the package's `mails/` folder the first
35
+ time one is sent, and kept.
36
+
37
+ ## Options
38
+
39
+ | Option | Type | Default | Effect |
40
+ | --- | --- | --- | --- |
41
+ | `mailer` | `Mailer` | required | The transport every e-mail is handed to |
42
+ | `from` | `Address` | required | The sender: `'noreply@acme.example'` or `{ name, address }` |
43
+ | `replyTo` | `Address` | none | Where a reply goes, when not to `from` |
44
+ | `brand` | `string` | required | The name the e-mails show, in the header, the body and the footer. Text: escaped in the HTML |
45
+ | `links.verifyEmail` | `(token) => string` | required | The page that confirms an address, given the one-time token |
46
+ | `links.resetPassword` | `(token) => string` | required | The page that sets a new password, given the one-time token |
47
+ | `links.secureAccount` | `() => string` | required | Where a user who made no change secures their account: the notices link to it |
48
+ | `locales` | `readonly L[]` | `['en', 'fr']` | The locales sent in — see [Locales](locales.md) |
49
+ | `fallbackLocale` | one of `locales` | `'en'`, else the first of `locales` | The locale when the recipient wants none of `locales` |
50
+ | `templates` | `Partial<JanusMailTemplates<L>>` | none | Your own templates, over the defaults — see [Templates](templates.md) |
51
+
52
+ Every link is called at send time, and must answer an absolute `http:`,
53
+ `https:` or `mailto:` URL: a default template refuses anything else with
54
+ `MailRefused`, before the mailer is called. `mailto:` is accepted on purpose:
55
+ `secureAccount: () => 'mailto:security@acme.example'` points a user who made
56
+ no change at your support desk. A link must be answered synchronously, as a
57
+ string — an `async` function or a `URL` object is a compile error in
58
+ TypeScript, and a `TypeError` naming the call at send time in JavaScript,
59
+ `janusMail.verifyEmail: links.verifyEmail(token) must answer a string`.
60
+ Encode a token you put in a query string yourself (`encodeURIComponent`).
61
+
62
+ `links`, `from` and `replyTo` are copied and frozen when `janusMail()` is
63
+ called: what was checked then is what every send uses, and changing your
64
+ object afterwards changes nothing.
65
+
66
+ A wrong option is a bare `TypeError` when `janusMail()` is called, naming the
67
+ option and never its value — every message is in
68
+ [troubleshooting](../troubleshooting.md#wiring--janusmail).
69
+
70
+ ## The methods
71
+
72
+ Each method renders its e-mail with its template, hands it to the mailer,
73
+ and answers the mailer's `SentMail` (`{ messageId }`).
74
+
75
+ ### `verifyEmail(issued, to)`
76
+
77
+ ```ts
78
+ const issued = await auth.verifyEmail.send(user); // IssuedToken: { token, email, expiresAt }
79
+ await mail.verifyEmail(issued, { name: user.name, locale: user.locale });
80
+ ```
81
+
82
+ Sent to `issued.email`, with the link `links.verifyEmail(issued.token)`.
83
+
84
+ ### `resetPassword(issued, to)`
85
+
86
+ ```ts
87
+ const issued = await auth.resetPassword.request(email); // (IssuedToken & { user }) | null
88
+ if (issued !== null) {
89
+ await mail.resetPassword(issued, { name: issued.user.name, locale: issued.user.locale });
90
+ }
91
+ ```
92
+
93
+ `request` answers `null` for an address nobody holds, and the compiler
94
+ refuses `null` here: check it first, and answer the visitor the same either
95
+ way. Sent to `issued.email`, with `links.resetPassword(issued.token)`.
96
+
97
+ ### `signInCode(issued, to?)`
98
+
99
+ ```ts
100
+ const issued = await auth.signInCode.request(email); // IssuedCode: { code, challenge, email, expiresAt, user } | null
101
+ if (issued !== null) await mail.signInCode(issued, { locale: issued.user.locale });
102
+ ```
103
+
104
+ It reads `issued.code` and `issued.email`, **and nothing else**: the
105
+ challenge is the visitor's secret, and neither the default template nor an
106
+ override is ever given it. `to` is optional — the e-mail greets nobody by
107
+ name — and only its `locale` is read.
108
+
109
+ ### `passwordChanged(to)`
110
+
111
+ ```ts
112
+ const changed = await auth.changePassword(user, { current, next });
113
+ await mail.passwordChanged({ name: changed.name, locale: changed.locale, email: changed.email });
114
+ ```
115
+
116
+ A notice: the password of the account at `to.email` changed, with a link to
117
+ `links.secureAccount()` for a user who did not change it. Send it after
118
+ `changePassword`, `setPassword` and `resetPassword.confirm`.
119
+
120
+ ### `emailChanged(to)`
121
+
122
+ ```ts
123
+ const formerEmail = user.email;
124
+ const updated = await auth.update(user, { email: next });
125
+ await mail.emailChanged({ name: updated.name, locale: updated.locale, formerEmail, newEmail: updated.email });
126
+ ```
127
+
128
+ Sent to **`formerEmail`**: the owner of the old address is the one to warn,
129
+ since whoever changed it already controls the new one. It names `newEmail`,
130
+ and links to `links.secureAccount()`. Read the former address before the
131
+ update: the user `update` answers already holds the new one.
132
+
133
+ ## Where each e-mail goes
134
+
135
+ | Method | To | Never to |
136
+ | --- | --- | --- |
137
+ | `verifyEmail` | `issued.email` | what the visitor typed |
138
+ | `resetPassword` | `issued.email` | the `email` passed to `request` |
139
+ | `signInCode` | `issued.email` | the `email` passed to `request` |
140
+ | `passwordChanged` | `to.email` | — |
141
+ | `emailChanged` | `to.formerEmail` | `to.newEmail` |
142
+
143
+ `issued.email` is the address the user's record holds, as they registered
144
+ it; what the visitor typed may differ in case or spacing. The recipient is a
145
+ bare address: `to.name` is used in the greeting, not in the `To` header.
146
+
147
+ Only `subject`, `html` and `text` are read from what a template answers. A
148
+ template cannot add a recipient or a header: `to`, `from`, `replyTo` and
149
+ `headers` are this package's alone.
150
+
151
+ ## When a send fails
152
+
153
+ **Every method rejects with the error it met, untouched.** This package
154
+ defines no error class and wraps nothing:
155
+
156
+ | Error | From | Means | Answer |
157
+ | --- | --- | --- | --- |
158
+ | `MailFailure` (`MAIL_FAILED`) | the mailer | The transport could not hand the e-mail over. Nothing is known to have been sent | A `503`, or a retry from a queue |
159
+ | `MailRefused` (`MAIL_REFUSED`) | the renderer, or the mailer | The e-mail itself is wrong: a link that is not `http:`, `https:` or `mailto:`, an address that is not one | A bug to fix; sending it again fails again |
160
+ | `TypeError` | this package, or the renderer | A call without the value a flow answered — `janusMail.resetPassword: token must be a string` — or, in JavaScript, a link that is not a string (a compile error in TypeScript) — `janusMail.verifyEmail: links.verifyEmail(token) must answer a string` | A bug to fix |
161
+ | `Error` from `createMailRenderer` | the renderer | `mails/` is missing where the package runs: a bundler inlined `@nxgt/janus-mail`, or a deploy kept `dist/` only | Keep `@nxgt/janus-mail` external to your bundle and deploy its `mails/` with it; see [troubleshooting](../troubleshooting.md#createmailrenderer-mailsmail-manifestjson-cannot-be-read--run-maizzle-build-and-deploy-its-output-folder) |
162
+
163
+ `instanceof` holds against the classes of your own `@nxgt/mail`, since it is
164
+ a peer: one copy defines them.
165
+
166
+ ```ts
167
+ import { MailError } from '@nxgt/mail';
168
+ import { JanusError } from '@nxgt/janus';
169
+
170
+ export async function sendVerification(user: User): Promise<Response> {
171
+ try {
172
+ await mail.verifyEmail(await auth.verifyEmail.send(user), { name: user.name, locale: user.locale });
173
+ return new Response(null, { status: 202 });
174
+ } catch (error) {
175
+ if (error instanceof MailError && error.code === 'MAIL_FAILED') {
176
+ return Response.json({ code: 'MAIL_FAILED' }, { status: 503 }); // offer to send it again
177
+ }
178
+ if (error instanceof JanusError && error.code === 'STORE_FAILED') {
179
+ return Response.json({ code: 'STORE_FAILED' }, { status: 503 });
180
+ }
181
+ throw error; // MAIL_REFUSED and TypeError are bugs: a 500
182
+ }
183
+ }
184
+ ```
185
+
186
+ **Never fire and forget.** `void mail.signInCode(issued)` turns a failure
187
+ into an unhandled rejection. The [sign-in code guide of
188
+ `@nxgt/janus`](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/sign-in-code.md)
189
+ advises sending *after* answering, so the time an answer takes does not tell
190
+ whether the address exists: hand the send to a queue that awaits it and
191
+ retries a `MAIL_FAILED`, rather than to a promise nobody awaits.
192
+
193
+ ## Testing
194
+
195
+ `createMemoryMailer()` from `@nxgt/mail` keeps an outbox, and can be told to
196
+ fail:
197
+
198
+ ```ts
199
+ import { expect, it } from 'bun:test';
200
+ import { createMemoryMailer, MailFailure } from '@nxgt/mail';
201
+ import { janusMail } from '@nxgt/janus-mail';
202
+
203
+ const links = {
204
+ verifyEmail: (token: string) => `https://acme.example/verify?token=${token}`,
205
+ resetPassword: (token: string) => `https://acme.example/reset?token=${token}`,
206
+ secureAccount: () => 'https://acme.example/account/security',
207
+ };
208
+
209
+ it('sends the code, and never the challenge', async () => {
210
+ const mailer = createMemoryMailer();
211
+ const mail = janusMail({ mailer, from: 'noreply@acme.example', brand: 'Acme', links });
212
+ const issued = { code: '042817', challenge: 'secret', email: 'ada@example.com' };
213
+
214
+ await mail.signInCode(issued, { locale: 'fr-CA' });
215
+
216
+ const [sent] = mailer.sent;
217
+ expect(sent?.to).toBe('ada@example.com');
218
+ expect(sent?.subject).toBe('Votre code de connexion : 042817');
219
+ expect(`${sent?.html}${sent?.text}`).not.toContain('secret');
220
+ });
221
+
222
+ it('says so when the mailer is down', async () => {
223
+ const mailer = createMemoryMailer();
224
+ mailer.failNext();
225
+ const mail = janusMail({ mailer, from: 'noreply@acme.example', brand: 'Acme', links });
226
+
227
+ const error = await mail
228
+ .signInCode({ code: '042817', email: 'ada@example.com' })
229
+ .then(() => null, (e: unknown) => e); // settled where it is created
230
+
231
+ expect(error).toBeInstanceOf(MailFailure);
232
+ expect(mailer.attempts).toBe(1); // tried once: nothing retried in secret
233
+ });
234
+ ```
235
+
236
+ ## See also
237
+
238
+ - [Templates](templates.md) — replacing an e-mail.
239
+ - [Locales](locales.md) — how the locale is picked.
240
+ - `@nxgt/mail`'s
241
+ [sending guide](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/sending.md)
242
+ — the `Mailer` port and its errors.
@@ -0,0 +1,183 @@
1
+ # Templates
2
+
3
+ This page is for changing what an e-mail says or looks like: what a template
4
+ is given and answers, replacing some of the five, using the defaults on their
5
+ own, and what the defaults say.
6
+
7
+ ```ts
8
+ import { janusMail } from '@nxgt/janus-mail';
9
+
10
+ const mail = janusMail({
11
+ mailer,
12
+ from: 'noreply@acme.example',
13
+ brand: 'Acme',
14
+ links,
15
+ templates: {
16
+ signInCode: ({ brand, code, locale }) => ({
17
+ subject: locale === 'fr' ? `Votre code ${brand} : ${code}` : `Your ${brand} code: ${code}`,
18
+ html: `<p style="font-size:24px;letter-spacing:4px">${code}</p>`,
19
+ text: code,
20
+ }),
21
+ },
22
+ });
23
+ ```
24
+
25
+ The sign-in code e-mail is now yours; the four others stay the defaults.
26
+
27
+ ## What a template is
28
+
29
+ ```ts
30
+ import type { Rendered } from '@nxgt/mail';
31
+
32
+ type JanusMailTemplate<V, L extends string = 'en' | 'fr'> = (
33
+ variables: V & { readonly locale: L },
34
+ ) => Rendered | PromiseLike<Rendered>;
35
+
36
+ interface Rendered {
37
+ readonly subject: string;
38
+ readonly html: string;
39
+ readonly text: string;
40
+ }
41
+ ```
42
+
43
+ A template is a function: its variables and the locale picked for the
44
+ recipient in, the three parts of the e-mail out — synchronously or as a
45
+ promise. Anything that answers that shape fits: React Email's `render`, a
46
+ template string, another Maizzle build read with `@nxgt/mail/renderer`.
47
+
48
+ **Escaping is then the template's job.** The defaults escape every value in
49
+ the HTML; a template of yours that writes `name` into HTML must escape it
50
+ itself — a user's name is free text.
51
+
52
+ Only `subject`, `html` and `text` are read from what it answers: a `to`, a
53
+ `from` or `headers` it adds are ignored, so a template can never redirect an
54
+ e-mail.
55
+
56
+ ## What each template is given
57
+
58
+ `JanusMailVariables`, by template:
59
+
60
+ | Template | Variables | Sent by |
61
+ | --- | --- | --- |
62
+ | `verifyEmail` | `brand`, `name`, `link` | `mail.verifyEmail(issued, to)` |
63
+ | `resetPassword` | `brand`, `name`, `link` | `mail.resetPassword(issued, to)` |
64
+ | `signInCode` | `brand`, `code` | `mail.signInCode(issued, to?)` |
65
+ | `passwordChanged` | `brand`, `name`, `link` | `mail.passwordChanged(to)` |
66
+ | `emailChanged` | `brand`, `name`, `link`, `newEmail` | `mail.emailChanged(to)` |
67
+
68
+ Every one also gets `locale`, one of `locales`. Every value is a string:
69
+ `brand` from the options, `name` from the recipient, `link` from `links` —
70
+ `links.secureAccount()` for the two notices — `code` and `newEmail` from the
71
+ call.
72
+
73
+ `signInCode` is **never given the challenge**: it is not in its variables,
74
+ and the method never reads it. `expiresAt` is not given either — the flows
75
+ answer it, so a template that states the lifetime takes it from a closure:
76
+
77
+ ```ts
78
+ const mail = janusMail({
79
+ mailer, from, brand: 'Acme', links,
80
+ templates: {
81
+ // The code lives ten minutes, @nxgt/janus's default for tokens.signInCode.
82
+ signInCode: ({ code, locale }) =>
83
+ locale === 'fr'
84
+ ? { subject: `Code : ${code}`, html: `<p>${code} — valable 10 minutes.</p>`, text: `${code} — valable 10 minutes.` }
85
+ : { subject: `Code: ${code}`, html: `<p>${code} — valid for 10 minutes.</p>`, text: `${code} — valid for 10 minutes.` },
86
+ },
87
+ });
88
+ ```
89
+
90
+ A template that reads a variable its e-mail does not have is a compile error:
91
+
92
+ ```ts
93
+ // @ts-expect-error — Property 'name' does not exist: the sign-in code e-mail greets nobody
94
+ templates: { signInCode: ({ name }) => rendered },
95
+ ```
96
+
97
+ ## Some templates, or all of them
98
+
99
+ `templates` is a plain object, or a class with template fields: a class's
100
+ methods live on its prototype and are refused — see
101
+ [troubleshooting](../troubleshooting.md#janusmail-templatesname-is-not-an-own-enumerable-property--pass-a-plain-object-as--name-variables--rendered-).
102
+
103
+ `templates` is `Partial` while `locales` stays within `en` and `fr`: give
104
+ any of the five, and the defaults render the rest. Once `locales` holds
105
+ another locale, it takes **all five** — see
106
+ [Adding a locale](locales.md#adding-a-locale).
107
+
108
+ `mail.templates` answers the five in use — yours, and the defaults for the
109
+ rest — frozen:
110
+
111
+ ```ts
112
+ const rendered = await mail.templates.verifyEmail({ brand: 'Acme', name: 'Ada', link, locale: 'fr' });
113
+ ```
114
+
115
+ ## The defaults alone — `janusTemplates()`
116
+
117
+ ```ts
118
+ import { janusTemplates } from '@nxgt/janus-mail';
119
+
120
+ const defaults = janusTemplates();
121
+ const { subject, html, text } = await defaults.resetPassword({
122
+ brand: 'Acme',
123
+ name: 'Ada',
124
+ link: 'https://acme.example/reset?token=abc',
125
+ locale: 'fr',
126
+ });
127
+ ```
128
+
129
+ It answers the five defaults, typed for `'en' | 'fr'`, without a mailer:
130
+ to preview an e-mail, to wrap one — add a line to the default text, say — or
131
+ to send one through something other than `janusMail()`. A default reused for
132
+ a wider set of locales is a compile error: it could not render the one it
133
+ lacks.
134
+
135
+ ```ts
136
+ import { janusTemplates, type JanusMailTemplates } from '@nxgt/janus-mail';
137
+
138
+ const defaults = janusTemplates();
139
+
140
+ // Wrapping a default: the same e-mail, with a line added to its text part.
141
+ const templates: Partial<JanusMailTemplates> = {
142
+ verifyEmail: async (variables) => {
143
+ const rendered = await defaults.verifyEmail(variables);
144
+ return { ...rendered, text: `${rendered.text}\n\nAcme — 1 Example Street` };
145
+ },
146
+ };
147
+ ```
148
+
149
+ The default templates share one renderer, created the first time one of
150
+ them renders: that is when the package's `mails/` folder is read, once.
151
+
152
+ ## What the defaults say
153
+
154
+ The defaults are [`@nxgt/mail-presets`](https://www.npmjs.com/package/@nxgt/mail-presets)
155
+ 0.1.0, built with a neutral grey theme and no logo:
156
+
157
+ | Template | Built from | Subject (`en`) | Subject (`fr`) |
158
+ | --- | --- | --- | --- |
159
+ | `verifyEmail` | `verify-email` | Confirm your e-mail address | Confirmez votre adresse e-mail |
160
+ | `resetPassword` | `reset-password` | Reset your password | Réinitialisez votre mot de passe |
161
+ | `signInCode` | `sign-in-code` | Your sign-in code: `042817` | Votre code de connexion : `042817` |
162
+ | `passwordChanged` | `password-changed` | Your password was changed | Votre mot de passe a été modifié |
163
+ | `emailChanged` | `email-changed` | Your e-mail address was changed | Votre adresse e-mail a été modifiée |
164
+
165
+ Each has a header and a footer with the brand, a heading, a greeting by name
166
+ (but `signInCode`), a button with its link and the link again in text for a
167
+ client that shows no button, and a text part. The two notices add a warning:
168
+ *if this was not you, secure your account now*.
169
+
170
+ **They do not state how long a link or a code lasts.** Replace the template
171
+ when yours must: see above.
172
+
173
+ **The brand is text.** It is written in the header, the body and the footer,
174
+ escaped — no logo and no link, which would need absolute URLs known when the
175
+ package is built. A logo takes a template of your own; the
176
+ [roadmap](../roadmap.md) has a build of your own brand.
177
+
178
+ ## See also
179
+
180
+ - [Sending](sending.md) — what each method passes its template.
181
+ - [Locales](locales.md) — the `locale` a template is given.
182
+ - `@nxgt/mail-presets`' [e-mails guide](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-presets/docs/guide/emails.md)
183
+ — every message of the defaults, in both locales.
@@ -0,0 +1,52 @@
1
+ # Roadmap
2
+
3
+ Where `@nxgt/janus-mail` is heading. A direction, not a commitment: there are
4
+ no dates here, and the version something shipped in is the only number.
5
+
6
+ ## Now
7
+
8
+ Nothing between releases.
9
+
10
+ ## Next
11
+
12
+ - **The other presets** — `magic-link`, `new-sign-in`, `welcome` and
13
+ `invitation` from `@nxgt/mail-presets`, each once `@nxgt/janus` has a flow
14
+ that sends it: a sign-in link, a sign-in from a new device, a user created,
15
+ an invitation to a user type.
16
+ - **The expiry in the e-mail** — "the link expires in 24 hours", from the
17
+ flow's `expiresAt`, once the presets' messages take it.
18
+
19
+ ## Later
20
+
21
+ - **An inlined build, for edge runtimes** — the default e-mails compiled into
22
+ the JavaScript itself rather than read from `mails/` with `node:fs`, so the
23
+ package runs where there is no file system.
24
+ - **More built-in languages** — each one a catalogue of the presets'
25
+ messages, so a locale beyond `en` and `fr` no longer requires every
26
+ template.
27
+ - **Your brand at build time** — a logo, a link and your colours, from a
28
+ Maizzle build of your own over the same presets, read by the same
29
+ renderer.
30
+
31
+ ## Not planned
32
+
33
+ - **An error class of its own** — a send rejects with the mailer's
34
+ `MailFailure` or `MailRefused`, untouched, so one `instanceof` against
35
+ `@nxgt/mail` answers every failure, whichever package sent the e-mail.
36
+ - **Retries or a queue** — a retry is the application's decision, made where
37
+ it can see it; hand a send to a queue that retries a `MAIL_FAILED`.
38
+ - **Sending from inside `janus()`** — `@nxgt/janus` stays free of e-mail:
39
+ its flows answer what to send, and this package is called with that answer.
40
+ An application that sends its own way needs neither this package nor a
41
+ hook to turn off.
42
+ - **The challenge in an e-mail** — a sign-in code e-mail carries the code and
43
+ never the challenge, not even through an override.
44
+
45
+ ## Shipped
46
+
47
+ Newest first; from the first release on, the package's CHANGELOG holds every one.
48
+
49
+ - **The first release, v0.1.0.** The five e-mails of `@nxgt/janus`'s flows,
50
+ in English and French, over any `@nxgt/mail` transport: `janusMail()` and
51
+ `janusTemplates()`. Built with Maizzle when the package is built, shipped
52
+ in `mails/`, and only filled in at send time, every value escaped.