@nxgt/janus-mail 0.4.1 → 0.5.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 (54) hide show
  1. package/README.md +78 -25
  2. package/dist/codes-left.d.ts +6 -0
  3. package/dist/codes-left.d.ts.map +1 -0
  4. package/dist/generated/codes-left.d.ts +3 -0
  5. package/dist/generated/codes-left.d.ts.map +1 -0
  6. package/dist/generated/mail.d.ts +7 -0
  7. package/dist/generated/mail.d.ts.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +130 -21
  10. package/dist/index.js.map +11 -6
  11. package/dist/janus-mail.d.ts +1 -0
  12. package/dist/janus-mail.d.ts.map +1 -1
  13. package/dist/links.d.ts +10 -0
  14. package/dist/links.d.ts.map +1 -0
  15. package/dist/options.d.ts.map +1 -1
  16. package/dist/recovery-mail.d.ts +5 -0
  17. package/dist/recovery-mail.d.ts.map +1 -0
  18. package/dist/refuse.d.ts +3 -0
  19. package/dist/refuse.d.ts.map +1 -0
  20. package/dist/template-types.d.ts +15 -2
  21. package/dist/template-types.d.ts.map +1 -1
  22. package/dist/templates.d.ts +1 -1
  23. package/dist/templates.d.ts.map +1 -1
  24. package/dist/types.d.ts +25 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/docs/README.md +4 -3
  27. package/docs/guide/building.md +58 -9
  28. package/docs/guide/locales.md +13 -4
  29. package/docs/guide/sending.md +61 -7
  30. package/docs/guide/templates.md +41 -16
  31. package/docs/roadmap.md +15 -1
  32. package/docs/troubleshooting.md +114 -21
  33. package/mails/en/email-changed.html +5 -5
  34. package/mails/en/password-changed.html +5 -5
  35. package/mails/en/recovery-code-used.html +53 -0
  36. package/mails/en/recovery-code-used.txt +23 -0
  37. package/mails/en/reset-password.html +6 -6
  38. package/mails/en/sign-in-code.html +3 -3
  39. package/mails/en/two-factor-disabled.html +5 -5
  40. package/mails/en/two-factor-enabled.html +5 -5
  41. package/mails/en/verify-email.html +6 -6
  42. package/mails/en/welcome.html +5 -5
  43. package/mails/fr/email-changed.html +5 -5
  44. package/mails/fr/password-changed.html +5 -5
  45. package/mails/fr/recovery-code-used.html +52 -0
  46. package/mails/fr/recovery-code-used.txt +23 -0
  47. package/mails/fr/reset-password.html +6 -6
  48. package/mails/fr/sign-in-code.html +3 -3
  49. package/mails/fr/two-factor-disabled.html +5 -5
  50. package/mails/fr/two-factor-enabled.html +5 -5
  51. package/mails/fr/verify-email.html +6 -6
  52. package/mails/fr/welcome.html +5 -5
  53. package/mails/mail-manifest.json +26 -0
  54. package/package.json +11 -10
package/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  The e-mails of [`@nxgt/janus`](https://www.npmjs.com/package/@nxgt/janus)'s
4
4
  flows, ready to send: **e-mail verification, password reset, sign-in code,
5
5
  password changed, e-mail changed, two-factor authentication turned on or
6
- off, and a welcome to a new user**, in English and French, with your brand
6
+ off, a recovery code used, and a welcome to a new user**, in English and
7
+ French, with your brand
7
8
  in them. `@nxgt/janus` sends no e-mail — its flows answer what to send — and
8
9
  this package turns that answer into an e-mail and hands it to any
9
10
  [`@nxgt/mail`](https://www.npmjs.com/package/@nxgt/mail) transport.
@@ -21,6 +22,7 @@ const mail = janusMail({
21
22
  resetPassword: (token) => `https://acme.example/reset?token=${token}`,
22
23
  secureAccount: () => 'https://acme.example/account/security',
23
24
  getStarted: () => 'https://acme.example/',
25
+ recoveryCodes: () => 'https://acme.example/account/recovery-codes', // optional
24
26
  },
25
27
  });
26
28
 
@@ -43,9 +45,9 @@ bun add @nxgt/janus-mail @nxgt/mail @nxgt/janus
43
45
  bun add -d typescript
44
46
  ```
45
47
 
46
- Three peers, all required: `@nxgt/mail` (0.1 or later, below 1 — the
47
- transports of 0.4 included), which defines the `Mailer` port and the errors;
48
- `@nxgt/janus` (0.9), whose flows' answers the methods take — types only,
48
+ Three peers, all required: `@nxgt/mail` (0.1 or later, below 2 — 1.0 and
49
+ its transports included), which defines the `Mailer` port and the errors;
50
+ `@nxgt/janus` (0.11), whose flows' answers the methods take — types only,
49
51
  nothing of it is loaded; and `typescript` (6). **No Maizzle, no Vue, no
50
52
  Tailwind**: they run at this package's build, not in yours.
51
53
 
@@ -53,18 +55,20 @@ The `@nxgt/mail` floor is tested, not claimed: the package's specs and its
53
55
  typecheck run on `@nxgt/mail` 0.1.0 as well, in the Floors job, on every CI
54
56
  run.
55
57
 
56
- It reads its prebuilt e-mails with `node:fs`: **Node, Bun or Deno**, not an edge
57
- runtime. Like `@nxgt/janus`, it expects `"moduleResolution": "bundler"`.
58
+ It reads its prebuilt e-mails with `node:fs`: **Node (20 or later), Bun or
59
+ Deno**, not an edge runtime. Rebuilding the e-mails from this repository
60
+ needs Node `^22.22.3`, `^24.15.0` or `>=26`, for Maizzle — see
61
+ [Building](docs/guide/building.md); installing the package needs no build. Like `@nxgt/janus`, it expects `"moduleResolution": "bundler"`.
58
62
 
59
63
  ## API
60
64
 
61
65
  | Export | What it is |
62
66
  | --- | --- |
63
- | `janusMail(options)` | The e-mails, over your mailer: `verifyEmail`, `resetPassword`, `signInCode`, `passwordChanged`, `emailChanged`, `twoFactorEnabled`, `twoFactorDisabled`, `welcome`, and `templates` and `locales`. Options: `mailer`, `from`, `replyTo?`, `brand`, `links` (`verifyEmail`, `resetPassword`, `secureAccount`, `getStarted`), `locales?`, `fallbackLocale?`, `templates?`, `clock?`. A wrong option is a bare `TypeError`, thrown here |
64
- | `janusTemplates()` | The eight default templates alone, each `(variables & { locale }) => Rendered` |
67
+ | `janusMail(options)` | The e-mails, over your mailer: `verifyEmail`, `resetPassword`, `signInCode`, `passwordChanged`, `emailChanged`, `twoFactorEnabled`, `twoFactorDisabled`, `recoveryCodeUsed`, `welcome`, and `templates` and `locales`. Options: `mailer`, `from`, `replyTo?`, `brand`, `links` (`verifyEmail`, `resetPassword`, `secureAccount`, `getStarted`, and `recoveryCodes?`), `locales?`, `fallbackLocale?`, `templates?`, `clock?`. A wrong option is a bare `TypeError`, thrown here |
68
+ | `janusTemplates()` | The nine default templates alone, each `(variables & { locale }) => Rendered` |
65
69
  | `JanusMail<L>`, `JanusMailOptions<L>` | What `janusMail()` answers and takes, for the locales `L` |
66
- | `JanusMailTemplate<V, L>`, `JanusMailTemplates<L>`, `JanusMailTemplateName` | One template, the eight of them, and their names |
67
- | `JanusMailVariables` | What each template is given: `brand`, and `name`, `link`, `code`, `expiresIn` or `newEmail` as its e-mail needs |
70
+ | `JanusMailTemplate<V, L>`, `JanusMailTemplates<L>`, `JanusMailTemplateName` | One template, the nine of them, and their names |
71
+ | `JanusMailVariables` | What each template is given: `brand`, and `name`, `link`, `code`, `expiresIn`, `newEmail`, `when` or `recoveryCodesLeft` as its e-mail needs |
68
72
  | `JanusMailSendOptions` | The third argument of `verifyEmail`, `resetPassword` and `signInCode`: `{ expiresIn? }`, the expiry as text, over the one derived |
69
73
  | `JanusMailLocale` | `'en' \| 'fr'`: the locales the defaults are built in |
70
74
  | `JanusMailLinks`, `Recipient` | The `links` option; who an e-mail is for — `{ name, locale? }` |
@@ -73,8 +77,8 @@ Each method answers the mailer's `SentMail` and **rejects with the mailer's
73
77
  `MailFailure` or `MailRefused`, untouched**: this package defines no error
74
78
  class. To retry a `MailFailure` or trace each send, wrap the mailer you pass:
75
79
  `withMailTelemetry(withRetry(mailer), { transport })` — `withRetry` from
76
- `@nxgt/mail` 0.8, `withMailTelemetry` from `@nxgt/mail/telemetry` 0.9 (0.8
77
- names it `withTelemetry`, a deprecated alias until 1.0); see
80
+ `@nxgt/mail` 0.8 or later, `withMailTelemetry` from `@nxgt/mail/telemetry`
81
+ 0.9 or later (0.8 names it `withTelemetry`, which 1.0 removed); see
78
82
  [Sending](docs/guide/sending.md#retrying-and-tracing-the-mailer).
79
83
 
80
84
  | Method | Sends | To |
@@ -86,6 +90,7 @@ names it `withTelemetry`, a deprecated alias until 1.0); see
86
90
  | `emailChanged(to)` | A notice naming `to.newEmail`, with `links.secureAccount()` | `to.formerEmail` |
87
91
  | `twoFactorEnabled(to)` | A notice: two-factor authentication was turned on, with `links.secureAccount()` | `to.email` |
88
92
  | `twoFactorDisabled(to)` | A notice: two-factor authentication was turned off, with `links.secureAccount()` | `to.email` |
93
+ | `recoveryCodeUsed(to, { when, recoveryCodesLeft })` | A notice: a recovery code was used, `when`, and how many are left — "You have 9 recovery codes left." — with `links.recoveryCodes()`, else `links.secureAccount()` | `to.email` |
89
94
  | `welcome(to)` | A welcome to a new user — "Welcome, Ada" — with `links.getStarted()` | `to.email` |
90
95
 
91
96
  The three e-mails of a link or a code say how long it lasts — "This link
@@ -200,6 +205,36 @@ out before the address is verified; to welcome proven addresses only, send
200
205
  it on `user.emailVerified` instead — see
201
206
  [Sending](docs/guide/sending.md#welcometo).
202
207
 
208
+ ### Telling a user a recovery code was used
209
+
210
+ `@nxgt/janus` sends `user.recoveryCodeUsed` once `secondFactor.recover`
211
+ spent a code: a sign-in without the user's phone. The event names the user
212
+ by id only, so read the user, and the codes left with
213
+ `secondFactor.recoveryCodesLeft` (`@nxgt/janus` 0.11):
214
+
215
+ ```ts
216
+ const auth = janus({
217
+ ...config,
218
+ async events(event) {
219
+ if (event.type === 'user.recoveryCodeUsed') {
220
+ const user = await auth.get(event.userId);
221
+ const recoveryCodesLeft = await auth.secondFactor.recoveryCodesLeft(user);
222
+ if (recoveryCodesLeft === null) return; // the factor was turned off since
223
+ const when = new Intl.DateTimeFormat(user.locale, { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris' })
224
+ .format(event.occurredAt);
225
+ await mail.recoveryCodeUsed({ name: user.name, locale: user.locale, email: user.email }, { when, recoveryCodesLeft });
226
+ }
227
+ },
228
+ });
229
+ ```
230
+
231
+ `when` is text: format it in the recipient's locale and time zone.
232
+ `recoveryCodesLeft` is the count, written as a sentence in the recipient's
233
+ locale — "You have 1 recovery code left.", "Il ne vous reste aucun code de
234
+ récupération." — or the sentence itself, as text. The button links to
235
+ `links.recoveryCodes()`, where the user regenerates their codes, and to
236
+ `links.secureAccount()` when you give no `recoveryCodes`.
237
+
203
238
  ### Replacing a template
204
239
 
205
240
  Any template can be your own function — React Email, a string, another
@@ -245,10 +280,19 @@ writes that name in its subject — "Welcome, <anything>" — so on
245
280
  `user.created` anyone can make your brand send it to any address. Once the
246
281
  address is proven, only its owner receives it.
247
282
 
248
- **Every link is required, `getStarted` included.** `links` written for 0.3
249
- has no `getStarted`: a compile error on `links`, and in JavaScript
250
- `janusMail: links.getStarted must be a function`. Add it even if you never
251
- send the welcome.
283
+ **Every link is required, `getStarted` included — but `recoveryCodes`.**
284
+ `links` written for 0.3 has no `getStarted`: a compile error on `links`, and
285
+ in JavaScript `janusMail: links.getStarted must be a function`. Add it even
286
+ if you never send the welcome. `recoveryCodes` is optional: without it,
287
+ `recoveryCodeUsed` links to `secureAccount`.
288
+
289
+ **The recovery code event carries no count.** `user.recoveryCodeUsed` names
290
+ the user only; pass `recoveryCodesLeft` as
291
+ `auth.secondFactor.recoveryCodesLeft(user)` answers it, once checked for
292
+ `null` — a user whose factor was turned off since has no codes to count, and
293
+ `null` is a compile error here. With a locale the defaults are not built in,
294
+ pass the sentence rather than the count: the build has no plural for it, and
295
+ a count is a `TypeError` at send.
252
296
 
253
297
  **`node:fs` means no edge runtime.** The default e-mails are read from the
254
298
  package's `mails/` folder, on the first one sent. On an edge runtime, pass
@@ -277,8 +321,9 @@ and are refused with a `TypeError`; its fields (`signInCode = (variables) =>
277
321
  …`) pass.
278
322
 
279
323
  **A locale beyond `en` and `fr` needs every template.** The defaults are
280
- built in those two only, so `locales: ['en', 'fr', 'de']` without all eight
281
- templates is a compile error, and a `TypeError` in JavaScript.
324
+ built in those two only, so `locales: ['en', 'fr', 'de']` without all nine
325
+ templates — `recoveryCodeUsed` included, since 0.5 — is a compile error, and
326
+ a `TypeError` in JavaScript.
282
327
 
283
328
  **The brand is text only.** `brand: 'Acme'` is written in the header, the
284
329
  body and the footer, escaped: no logo, no link, no markup. For those, replace
@@ -335,8 +380,8 @@ The symptoms and fixes are in [troubleshooting](docs/troubleshooting.md).
335
380
 
336
381
  ## Type safety, counted
337
382
 
338
- **Twenty-nine plausible mistakes, twenty-nine refused at compile time.**
339
- Four files hold one `@ts-expect-error` per mistake, beside the calls that
383
+ **Thirty-four plausible mistakes, thirty-four refused at compile time.**
384
+ Five files hold one `@ts-expect-error` per mistake, beside the calls that
340
385
  must keep compiling:
341
386
  [`test/types/send-refusals.ts`](test/types/send-refusals.ts) the eight of a
342
387
  send, 1 to 8;
@@ -344,9 +389,11 @@ send, 1 to 8;
344
389
  of `janusMail()`'s options, 9 to 20, beside adding a language with every
345
390
  template;
346
391
  [`test/types/expiry-refusals.ts`](test/types/expiry-refusals.ts) the five of
347
- the expiry and the clock, 21 to 25; and
392
+ the expiry and the clock, 21 to 25;
348
393
  [`test/types/notice-refusals.ts`](test/types/notice-refusals.ts) the four of
349
- the two-factor notices and the welcome, 26 to 29:
394
+ the two-factor notices and the welcome, 26 to 29; and
395
+ [`test/types/recovery-refusals.ts`](test/types/recovery-refusals.ts) the
396
+ five of the recovery code notice, 30 to 34:
350
397
 
351
398
  1. A sign-in code given to `verifyEmail`: it has no token.
352
399
  2. A one-time token given to `signInCode`: it has no code.
@@ -377,9 +424,15 @@ the two-factor notices and the welcome, 26 to 29:
377
424
  27. `twoFactorEnabled` given `emailChanged`'s `formerEmail` rather than `email`.
378
425
  28. The `user.created` event itself given to `welcome`: it has neither a name nor an address.
379
426
  29. `links` written before the welcome, without `getStarted`.
380
-
381
- In JavaScript, 19 to 23 and 26 to 28 are a `TypeError` at send time instead, naming the
382
- call or the field, and 25 and 29 one from `janusMail()`.
427
+ 30. The `user.recoveryCodeUsed` event itself given to `recoveryCodeUsed`: it has neither a name nor an address.
428
+ 31. `recoveryCodeUsed` without `when` and the count.
429
+ 32. The count as `recoveryCodesLeft()` answered it, not checked for `null` first.
430
+ 33. `when` given as the event's `Date` rather than the text in the recipient's locale and time zone.
431
+ 34. `links.recoveryCodes` given as a URL rather than a function.
432
+
433
+ In JavaScript, 19 to 23, 26 to 28 and 30 to 33 are a `TypeError` at send
434
+ time instead, naming the call or the field, and 25, 29 and 34 one from
435
+ `janusMail()`.
383
436
 
384
437
  [`test/types/variables.ts`](test/types/variables.ts) also holds
385
438
  `JanusMailVariables` equal to the variables of the build: an e-mail that
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The sentence for `count` codes left in `locale`, or `undefined` for a
3
+ * locale the default e-mails are not built in.
4
+ */
5
+ export declare function codesLeftText(locale: string, count: number): string | undefined;
6
+ //# sourceMappingURL=codes-left.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codes-left.d.ts","sourceRoot":"","sources":["../src/codes-left.ts"],"names":[],"mappings":"AAcA;;;GAGG;AACH,wBAAgB,aAAa,CAC5B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GACX,MAAM,GAAG,SAAS,CASpB"}
@@ -0,0 +1,3 @@
1
+ /** Each locale's plural for the recovery codes left: branches by selector, `null` where the count goes. */
2
+ export declare const CODES_LEFT: Readonly<Record<string, Readonly<Record<string, readonly (string | null)[]>>>>;
3
+ //# sourceMappingURL=codes-left.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codes-left.d.ts","sourceRoot":"","sources":["../../src/generated/codes-left.ts"],"names":[],"mappings":"AAGA,2GAA2G;AAC3G,eAAO,MAAM,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CA+BrG,CAAC"}
@@ -11,6 +11,13 @@ export interface MailEmails {
11
11
  readonly link: string;
12
12
  readonly name: string | number;
13
13
  };
14
+ "recovery-code-used": {
15
+ readonly brand: string | number;
16
+ readonly link: string;
17
+ readonly name: string | number;
18
+ readonly recoveryCodesLeft: string | number;
19
+ readonly when: string | number;
20
+ };
14
21
  "reset-password": {
15
22
  readonly brand: string | number;
16
23
  readonly expiresIn: string | number;
@@ -1 +1 @@
1
- {"version":3,"file":"mail.d.ts","sourceRoot":"","sources":["../../src/generated/mail.ts"],"names":[],"mappings":"AAGA,kFAAkF;AAClF,MAAM,WAAW,UAAU;IAC1B,eAAe,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAChJ,kBAAkB,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAC/G,gBAAgB,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAClJ,cAAc,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACzH,qBAAqB,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAClH,oBAAoB,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACjH,cAAc,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAChJ,SAAS,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACtG"}
1
+ {"version":3,"file":"mail.d.ts","sourceRoot":"","sources":["../../src/generated/mail.ts"],"names":[],"mappings":"AAGA,kFAAkF;AAClF,MAAM,WAAW,UAAU;IAC1B,eAAe,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAChJ,kBAAkB,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAC/G,oBAAoB,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAC9L,gBAAgB,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAClJ,cAAc,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACzH,qBAAqB,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAClH,oBAAoB,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACjH,cAAc,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAChJ,SAAS,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACtG"}
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `@nxgt/janus` sends no e-mail: its flows answer what to send — a one-time
5
5
  * token, a sign-in code, the address — and this package turns that answer
6
- * into an e-mail and hands it to an `@nxgt/mail` transport. Eight e-mails, in
6
+ * into an e-mail and hands it to an `@nxgt/mail` transport. Nine e-mails, in
7
7
  * English and French, prebuilt with Maizzle when this package is built, and
8
8
  * filled at send time with your brand, the recipient's name and your links.
9
9
  * Any of them can be replaced by a function of your own.
package/dist/index.js CHANGED
@@ -129,6 +129,47 @@ function noticeMails(options) {
129
129
  // src/generated/locales.ts
130
130
  var LOCALES = ["en", "fr"];
131
131
 
132
+ // src/refuse.ts
133
+ function refuse(message) {
134
+ throw new TypeError(`janusMail: ${message}`);
135
+ }
136
+
137
+ // src/links.ts
138
+ var REQUIRED_LINKS = [
139
+ "verifyEmail",
140
+ "resetPassword",
141
+ "secureAccount",
142
+ "getStarted"
143
+ ];
144
+ var OPTIONAL_LINKS = ["recoveryCodes"];
145
+ function checkLinks(links) {
146
+ if (typeof links !== "object" || links === null || Array.isArray(links)) {
147
+ refuse("links must be an object, as { verifyEmail, resetPassword, secureAccount, getStarted }");
148
+ }
149
+ const given = links;
150
+ for (const name of REQUIRED_LINKS) {
151
+ if (typeof given[name] !== "function") {
152
+ refuse(`links.${name} must be a function`);
153
+ }
154
+ }
155
+ for (const name of OPTIONAL_LINKS) {
156
+ if (given[name] !== undefined && typeof given[name] !== "function") {
157
+ refuse(`links.${name} must be a function, or left out`);
158
+ }
159
+ }
160
+ }
161
+ function frozenLinks(links) {
162
+ const given = links;
163
+ const names = [
164
+ ...REQUIRED_LINKS,
165
+ ...OPTIONAL_LINKS.filter((name) => given[name] !== undefined)
166
+ ];
167
+ return Object.freeze(Object.fromEntries(names.map((name) => [
168
+ name,
169
+ given[name].bind(links)
170
+ ])));
171
+ }
172
+
132
173
  // src/mails.ts
133
174
  import { fileURLToPath } from "node:url";
134
175
  import { createMailRenderer } from "@nxgt/mail/renderer";
@@ -149,6 +190,14 @@ function janusTemplates() {
149
190
  emailChanged: ({ brand, name, link, newEmail, locale }) => defaultRenderer().render("email-changed", { brand, name, link, newEmail }, { locale }),
150
191
  twoFactorEnabled: ({ brand, name, link, locale }) => defaultRenderer().render("two-factor-enabled", { brand, name, link }, { locale }),
151
192
  twoFactorDisabled: ({ brand, name, link, locale }) => defaultRenderer().render("two-factor-disabled", { brand, name, link }, { locale }),
193
+ recoveryCodeUsed: ({
194
+ brand,
195
+ name,
196
+ when,
197
+ recoveryCodesLeft,
198
+ link,
199
+ locale
200
+ }) => defaultRenderer().render("recovery-code-used", { brand, name, when, recoveryCodesLeft, link }, { locale }),
152
201
  welcome: ({ brand, name, link, locale }) => defaultRenderer().render("welcome", { brand, name, link }, { locale })
153
202
  });
154
203
  }
@@ -162,14 +211,9 @@ var TEMPLATE_NAMES = [
162
211
  "emailChanged",
163
212
  "twoFactorEnabled",
164
213
  "twoFactorDisabled",
214
+ "recoveryCodeUsed",
165
215
  "welcome"
166
216
  ];
167
- var LINK_NAMES = [
168
- "verifyEmail",
169
- "resetPassword",
170
- "secureAccount",
171
- "getStarted"
172
- ];
173
217
  var SYSTEM_CLOCK = Object.freeze({ now: () => new Date });
174
218
  function isObject(value) {
175
219
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -179,9 +223,6 @@ function isAddress(value) {
179
223
  return value.trim() !== "";
180
224
  return isObject(value) && typeof value.name === "string" && typeof value.address === "string" && value.address.trim() !== "";
181
225
  }
182
- function refuse(message) {
183
- throw new TypeError(`janusMail: ${message}`);
184
- }
185
226
  function checkSending(options) {
186
227
  const { mailer, from, replyTo, brand, links } = options;
187
228
  if (!isObject(mailer) || typeof mailer.send !== "function") {
@@ -196,14 +237,7 @@ function checkSending(options) {
196
237
  if (typeof brand !== "string" || brand.trim() === "") {
197
238
  refuse("brand must be the name the e-mails show, as 'Acme'");
198
239
  }
199
- if (!isObject(links)) {
200
- refuse("links must be an object, as { verifyEmail, resetPassword, secureAccount, getStarted }");
201
- }
202
- for (const name of LINK_NAMES) {
203
- if (typeof links[name] !== "function") {
204
- refuse(`links.${name} must be a function`);
205
- }
206
- }
240
+ checkLinks(links);
207
241
  }
208
242
  function resolveClock(clock) {
209
243
  if (clock === undefined)
@@ -269,9 +303,6 @@ function resolveTemplates(options, locales) {
269
303
  function frozenAddress(address) {
270
304
  return typeof address === "string" ? address : Object.freeze({ name: address.name, address: address.address });
271
305
  }
272
- function frozenLinks(links) {
273
- return Object.freeze(Object.fromEntries(LINK_NAMES.map((name) => [name, links[name].bind(links)])));
274
- }
275
306
  function resolveOptions(options) {
276
307
  if (!isObject(options)) {
277
308
  refuse("options must be an object, as { mailer, from, brand, links }");
@@ -291,12 +322,90 @@ function resolveOptions(options) {
291
322
  });
292
323
  }
293
324
 
325
+ // src/generated/codes-left.ts
326
+ var CODES_LEFT = {
327
+ en: {
328
+ "=0": [
329
+ "You have no recovery codes left."
330
+ ],
331
+ one: [
332
+ "You have ",
333
+ null,
334
+ " recovery code left."
335
+ ],
336
+ other: [
337
+ "You have ",
338
+ null,
339
+ " recovery codes left."
340
+ ]
341
+ },
342
+ fr: {
343
+ "=0": [
344
+ "Il ne vous reste aucun code de récupération."
345
+ ],
346
+ one: [
347
+ "Il vous reste ",
348
+ null,
349
+ " code de récupération."
350
+ ],
351
+ other: [
352
+ "Il vous reste ",
353
+ null,
354
+ " codes de récupération."
355
+ ]
356
+ }
357
+ };
358
+
359
+ // src/codes-left.ts
360
+ function codesLeftText(locale, count) {
361
+ if (!Object.hasOwn(CODES_LEFT, locale))
362
+ return;
363
+ const branches = CODES_LEFT[locale];
364
+ const branch = branches[`=${count}`] ?? branches[new Intl.PluralRules(locale).select(count)] ?? branches.other;
365
+ const digits = new Intl.NumberFormat(locale).format(count);
366
+ return (branch ?? []).map((part) => part ?? digits).join("");
367
+ }
368
+
369
+ // src/recovery-mail.ts
370
+ var METHOD = "recoveryCodeUsed";
371
+ var isCount = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
372
+ function codesLeft(used, locale) {
373
+ const given = typeof used === "object" && used !== null ? used.recoveryCodesLeft : undefined;
374
+ if (typeof given === "string")
375
+ return given;
376
+ if (!isCount(given)) {
377
+ throw new TypeError(`janusMail.${METHOD}: recoveryCodesLeft must be a count — a whole number, 0 or more — or the sentence to show`);
378
+ }
379
+ const text = codesLeftText(locale, given);
380
+ if (text === undefined) {
381
+ throw new TypeError(`janusMail.${METHOD}: recoveryCodesLeft must be the sentence to show in a locale the default e-mails are not built in`);
382
+ }
383
+ return text;
384
+ }
385
+ function recoveryMail(options) {
386
+ const { brand, links } = options;
387
+ return {
388
+ async recoveryCodeUsed(to, used) {
389
+ const locale = localeFor(options, to);
390
+ return send(options, METHOD, field(METHOD, to, "email"), {
391
+ brand,
392
+ name: field(METHOD, to, "name"),
393
+ when: field(METHOD, used, "when"),
394
+ recoveryCodesLeft: codesLeft(used, locale),
395
+ link: links.recoveryCodes === undefined ? link(METHOD, links.secureAccount(), "secureAccount()") : link(METHOD, links.recoveryCodes(), "recoveryCodes()"),
396
+ locale
397
+ });
398
+ }
399
+ };
400
+ }
401
+
294
402
  // src/janus-mail.ts
295
403
  function janusMail(options) {
296
404
  const resolved = resolveOptions(options);
297
405
  const mail = Object.freeze({
298
406
  ...expiringMails(resolved),
299
407
  ...noticeMails(resolved),
408
+ ...recoveryMail(resolved),
300
409
  templates: resolved.templates,
301
410
  locales: resolved.locales
302
411
  });
@@ -307,5 +416,5 @@ export {
307
416
  janusTemplates
308
417
  };
309
418
 
310
- //# debugId=D782DBFE9A92F69064756E2164756E21
419
+ //# debugId=E470EFFF013B3B0B64756E2164756E21
311
420
  //# sourceMappingURL=index.js.map