@nxgt/janus-mail 0.7.1 → 0.9.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.
@@ -82,12 +82,15 @@ const templates: JanusMailTemplates<Locale> = {
82
82
  resetPassword: ({ name, link, expiresIn, locale }) => render('reset-password', locale, { name, link, expiresIn }),
83
83
  signInCode: ({ code, expiresIn, locale }) => render('sign-in-code', locale, { code, expiresIn }),
84
84
  magicLink: ({ link, expiresIn, locale }) => render('magic-link', locale, { link, expiresIn }),
85
+ stepUp: ({ name, code, expiresIn, link, locale }) => render('confirm-action', locale, { name, code, expiresIn, link }),
85
86
  passwordChanged: ({ name, link, locale }) => render('password-changed', locale, { name, link }),
86
87
  emailChanged: ({ name, link, newEmail, locale }) => render('email-changed', locale, { name, link, newEmail }),
87
88
  twoFactorEnabled: ({ name, link, locale }) => render('two-factor-enabled', locale, { name, link }),
88
89
  twoFactorDisabled: ({ name, link, locale }) => render('two-factor-disabled', locale, { name, link }),
89
90
  recoveryCodeUsed: ({ name, when, recoveryCodesLeft, link, locale }) =>
90
91
  render('recovery-code-used', locale, { name, when, recoveryCodesLeft, link }),
92
+ newSignIn: ({ name, device, location, time, link, locale }) =>
93
+ render('new-sign-in', locale, { name, device, location, time, link }),
91
94
  welcome: ({ name, link, locale }) => render('welcome', locale, { name, link }),
92
95
  };
93
96
 
@@ -101,8 +104,10 @@ mail.locales; // readonly ('en' | 'fr' | 'de')[]
101
104
  plural of the codes left in `en` and `fr` only, so a German recipient's
102
105
  `mail.recoveryCodeUsed` takes the sentence as text — "Sie haben noch 3
103
106
  Wiederherstellungscodes." — and a count is a `TypeError` there.
104
- `recoveryCodeUsed` is the ninth template, since 0.5.0, and `magicLink` the
105
- tenth, since 0.7.0: a `templates` written for an earlier version no longer
107
+ `recoveryCodeUsed` is the ninth template, since 0.5.0, `magicLink` the
108
+ tenth, since 0.7.0, `stepUp` the eleventh, since 0.8.0, and `newSignIn` the
109
+ twelfth, since 0.9.0: a `templates`
110
+ written for an earlier version no longer
106
111
  compiles with a locale beyond `en` and `fr` — add the one it lacks. A
107
112
  renderer over a Maizzle build of your own, with
108
113
  `@nxgt/mail-presets` and a `locales/de.json`, is the closest to the defaults.
@@ -110,13 +115,13 @@ With only some templates, the call does not compile — the error lands on
110
115
  `templates`:
111
116
 
112
117
  ```text
113
- TS2740: Type '{ verifyEmail: () => Rendered; }' is missing the following properties from type 'JanusMailTemplates<"en" | "fr" | "de">': welcome, resetPassword, signInCode, magicLink, and 5 more.
118
+ TS2740: Type '{ verifyEmail: () => Rendered; }' is missing the following properties from type 'JanusMailTemplates<"en" | "fr" | "de">': welcome, resetPassword, signInCode, magicLink, and 7 more.
114
119
  ```
115
120
 
116
121
  and in JavaScript it throws when `janusMail()` is called:
117
122
 
118
123
  ```text
119
- TypeError: janusMail: the default templates are built in en and fr only — with another locale in locales, pass every template in templates; resetPassword, signInCode, magicLink, passwordChanged, emailChanged, twoFactorEnabled, twoFactorDisabled, recoveryCodeUsed, welcome missing
124
+ TypeError: janusMail: the default templates are built in en and fr only — with another locale in locales, pass every template in templates; resetPassword, signInCode, magicLink, stepUp, passwordChanged, emailChanged, twoFactorEnabled, twoFactorDisabled, recoveryCodeUsed, newSignIn, welcome missing
120
125
  ```
121
126
 
122
127
  A default template is typed for `'en' | 'fr'`, so reusing one in a wider set
@@ -129,4 +134,4 @@ Built-in locales for more languages are on the [roadmap](../roadmap.md).
129
134
 
130
135
  - `@nxgt/mail`'s [locales guide](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/locales.md)
131
136
  — `pickLocale` and `parseAcceptLanguage` in full.
132
- - [Templates](templates.md) — writing the ten for a new locale.
137
+ - [Templates](templates.md) — writing the twelve for a new locale.
@@ -46,7 +46,7 @@ time one is sent, and kept.
46
46
  | `brand` | `string` | required | The name the e-mails show, in the header, the body and the footer. Text: escaped in the HTML |
47
47
  | `links.verifyEmail` | `(token) => string` | required | The page that confirms an address, given the one-time token |
48
48
  | `links.resetPassword` | `(token) => string` | required | The page that sets a new password, given the one-time token |
49
- | `links.secureAccount` | `() => string` | required | Where a user who made no change secures their account: the notices link to it |
49
+ | `links.secureAccount` | `() => string` | required | Where a user who made no change secures their account: the notices link to it — the new sign-in's included — and so does the step-up's code, for a user who asked for none |
50
50
  | `links.getStarted` | `() => string` | required | Where a new user starts — your home page, or your sign-in page for an account someone else created: the welcome's **Get started** button links to it |
51
51
  | `links.magicLink` | `(token) => string` | none: `magicLink` is a `TypeError` without it | The page a sign-in link opens, given the one-time token. That page spends nothing: its button posts the token to the route that calls `auth.magicLink.confirm` |
52
52
  | `links.recoveryCodes` | `() => string` | `links.secureAccount` | Where a user regenerates their recovery codes: the recovery code notice links to it, from its **Secure my account** button |
@@ -145,6 +145,44 @@ nobody by name — and only its `locale` is read.
145
145
  visitor's secret, and no `token`: the compiler refuses it here, and
146
146
  JavaScript gets `janusMail.magicLink: token must be a string`.
147
147
 
148
+ ### `stepUp(issued, to, options?)`
149
+
150
+ ```ts
151
+ const current = await auth.authenticate(request); // the signed-in user, their session
152
+ if (current === null) return new Response(null, { status: 401 });
153
+ const issued = await auth.stepUp.request(current.user); // StepUpByEmail, or StepUpByApp with a second factor
154
+ if (issued.via === 'email') {
155
+ await mail.stepUp(issued, { name: current.user.name, locale: current.user.locale });
156
+ }
157
+ return Response.json({ challenge: issued.challenge, via: issued.via }); // the challenge stays with the user
158
+ ```
159
+
160
+ The code of a step-up — a signed-in user proving again who they are before
161
+ a sensitive action, `@nxgt/janus`'s
162
+ [step-up guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/step-up.md) —
163
+ sent to `issued.email`, greeting `to.name`: "Your confirmation code",
164
+ "Votre code de confirmation", the code, how long it lasts — "This code
165
+ expires in 10 minutes." — and a **Secure my account** button to
166
+ `links.secureAccount()`, for a user who asked for nothing. The e-mail names
167
+ no action: it says "something sensitive", whatever the route guards.
168
+
169
+ - **Narrow `via` first.** A user whose second factor is active confirms with
170
+ their app: `request` answers `via: 'secondFactor'`, with no code and no
171
+ address, and there is nothing to send. The compiler refuses the answer
172
+ un-narrowed; JavaScript gets
173
+ `janusMail.stepUp: via must be 'email' — a step-up confirmed with the user's app sends no e-mail`,
174
+ before anything is rendered. A type without `secondFactor` in `janus()`
175
+ answers `via: 'email'` alone, and needs no check.
176
+ - **`to` is required**, unlike `signInCode`'s: the e-mail greets the user by
177
+ name, and it is the user of the session, whose name you hold.
178
+ - It reads `issued.via`, `issued.code`, `issued.email` and
179
+ `issued.expiresAt`, **and nothing else**: the challenge never reaches a
180
+ template.
181
+ - **The link has no fallback, and needs none.** `links.secureAccount` is
182
+ required by `janusMail()`: the notices link to it too.
183
+ - **Rate-limit `stepUp.request` per user**, as the step-up guide says: each
184
+ request sends this e-mail.
185
+
148
186
  ### `passwordChanged(to)`
149
187
 
150
188
  ```ts
@@ -311,10 +349,56 @@ when opening the session then fails.
311
349
  codes; without it, `links.secureAccount()`. `recoveryCodes` is optional,
312
350
  so a `links` written for 0.4 still compiles.
313
351
 
352
+ ### `newSignIn(to, { device, time, location? })`
353
+
354
+ ```ts
355
+ // From the sign-in's answer, with @nxgt/janus given devices: { keys } and the device cookie:
356
+ const signedIn = await auth.signIn({ email, password }, { device: cookie ?? null });
357
+ if (signedIn.newDevice) {
358
+ const { name, email, locale } = signedIn.user;
359
+ await mail.newSignIn(
360
+ { name, email, locale },
361
+ {
362
+ device: describeDevice(request.headers.get('user-agent')), // yours: 'Firefox on macOS'
363
+ time: new Intl.DateTimeFormat(locale ?? 'en', {
364
+ dateStyle: 'long',
365
+ timeStyle: 'short',
366
+ timeZone: 'Europe/Paris', // the user's, when you keep it
367
+ }).format(signedIn.session.createdAt),
368
+ location: 'Lyon, France', // optional: from a geo-IP of yours
369
+ },
370
+ );
371
+ }
372
+ ```
373
+
374
+ A notice for the account at `to.email`: it was signed in from a device the
375
+ user had not signed in from — which device, where and when — with a
376
+ **Secure my account** button to `links.secureAccount()`. Send it when a
377
+ `@nxgt/janus` 0.17 sign-in answers `newDevice: true`, or on the
378
+ `user.newDeviceSignedIn` event it sends then — the event names the user by
379
+ id alone, so read the name, the locale and the address from the user, and
380
+ it carries no request to describe the device from.
381
+
382
+ - **Every value is your text**, already in the recipient's locale: `device`
383
+ as you describe the request — "Firefox on macOS", "Firefox sur macOS" —
384
+ and `time` formatted in their time zone: "29 septembre 2026 à 09:12".
385
+ The session's `createdAt`, or the event's `occurredAt`, is when it
386
+ happened.
387
+ - **`location` is optional, and yours.** `@nxgt/janus` sees no IP: a
388
+ location comes from a geo-IP of your own, if you have one. Left out, the
389
+ e-mail shows `—` — an em dash, which reads the same in English and
390
+ French — since every line of the summary is always there. `null` is a
391
+ compile error, and in JavaScript `janusMail.newSignIn: location must be a
392
+ string`: spread it in only when you have one,
393
+ `...(city === null ? {} : { location: city })`.
394
+ - **The text part** gives each line of the summary as its label, then its
395
+ value on the next line — `Device`, then `Firefox on macOS` — where every
396
+ other paragraph is one line.
397
+
314
398
  ## The expiry
315
399
 
316
- `verifyEmail`, `resetPassword`, `signInCode` and `magicLink` say how long
317
- the link or the code lasts — "This link expires in 1 hour.", "Ce code expire dans
400
+ `verifyEmail`, `resetPassword`, `signInCode`, `magicLink` and `stepUp` say
401
+ how long the link or the code lasts — "This link expires in 1 hour.", "Ce code expire dans
318
402
  10 minutes." The text is `expiresIn`, one of the template's variables,
319
403
  derived at send time from the flow's `issued.expiresAt`:
320
404
 
@@ -328,7 +412,7 @@ derived at send time from the flow's `issued.expiresAt`:
328
412
  `unitDisplay: 'long'`, in the recipient's locale: "3 hours", "3 heures".
329
413
 
330
414
  With `@nxgt/janus`'s defaults, a verification link says "1 day", a reset
331
- link "1 hour" and a sign-in code "10 minutes".
415
+ link "1 hour", and a sign-in code and a step-up's code "10 minutes".
332
416
 
333
417
  The time is `janusMail({ clock })`'s — pass the clock `janus({ clock })` was
334
418
  given, a `fixedClock` in tests — else the system clock:
@@ -363,10 +447,12 @@ a `TypeError`, and nothing reaches the mailer: revive the date with
363
447
  | `resetPassword` | `issued.email` | the `email` passed to `request` |
364
448
  | `signInCode` | `issued.email` | the `email` passed to `request` |
365
449
  | `magicLink` | `issued.email` | the `email` passed to `request` |
450
+ | `stepUp` | `issued.email` | an address the client sent |
366
451
  | `passwordChanged` | `to.email` | — |
367
452
  | `emailChanged` | `to.formerEmail` | `to.newEmail` |
368
453
  | `twoFactorEnabled`, `twoFactorDisabled` | `to.email` | — |
369
454
  | `recoveryCodeUsed` | `to.email` | — |
455
+ | `newSignIn` | `to.email` | — |
370
456
  | `welcome` | `to.email` | — |
371
457
 
372
458
  `issued.email` is the address the user's record holds, as they registered
@@ -386,7 +472,7 @@ defines no error class and wraps nothing:
386
472
  | --- | --- | --- | --- |
387
473
  | `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 |
388
474
  | `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 |
389
- | `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`. For [the expiry](#the-expiry): `janusMail.<method>: expiresAt must be a Date`, `janusMail.<method>: expiresAt is past — the link or code would not work`, `janusMail.<method>: expiresIn must be a string`, `janusMail.<method>: clock.now() must answer a Date` | A bug to fix — for a past `expiresAt`, issue a new token or code and send that |
475
+ | `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`. For [the expiry](#the-expiry): `janusMail.<method>: expiresAt must be a Date`, `janusMail.<method>: expiresAt is past — the link or code would not work`, `janusMail.<method>: expiresIn must be a string`, `janusMail.<method>: clock.now() must answer a Date`. For `stepUp`: `janusMail.stepUp: via must be 'email' — …` | A bug to fix — for a past `expiresAt`, issue a new token or code and send that |
390
476
  | `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) |
391
477
 
392
478
  `instanceof` holds against the classes of your own `@nxgt/mail`, since it is
@@ -1,7 +1,7 @@
1
1
  # Templates
2
2
 
3
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 ten, using the defaults on their
4
+ is given and answers, replacing some of the twelve, using the defaults on their
5
5
  own, and what the defaults say.
6
6
 
7
7
  ```ts
@@ -22,7 +22,7 @@ const mail = janusMail({
22
22
  });
23
23
  ```
24
24
 
25
- The sign-in code e-mail is now yours; the eight others stay the defaults.
25
+ The sign-in code e-mail is now yours; the eleven others stay the defaults.
26
26
 
27
27
  ## What a template is
28
28
 
@@ -63,19 +63,22 @@ e-mail.
63
63
  | `resetPassword` | `brand`, `name`, `link`, `expiresIn` | `mail.resetPassword(issued, to, options?)` |
64
64
  | `signInCode` | `brand`, `code`, `expiresIn` | `mail.signInCode(issued, to?, options?)` |
65
65
  | `magicLink` | `brand`, `link`, `expiresIn` | `mail.magicLink(issued, to?, options?)` |
66
+ | `stepUp` | `brand`, `name`, `code`, `expiresIn`, `link` | `mail.stepUp(issued, to, options?)` |
66
67
  | `passwordChanged` | `brand`, `name`, `link` | `mail.passwordChanged(to)` |
67
68
  | `emailChanged` | `brand`, `name`, `link`, `newEmail` | `mail.emailChanged(to)` |
68
69
  | `twoFactorEnabled` | `brand`, `name`, `link` | `mail.twoFactorEnabled(to)` |
69
70
  | `twoFactorDisabled` | `brand`, `name`, `link` | `mail.twoFactorDisabled(to)` |
70
71
  | `recoveryCodeUsed` | `brand`, `name`, `when`, `recoveryCodesLeft`, `link` | `mail.recoveryCodeUsed(to, { when, recoveryCodesLeft })` |
72
+ | `newSignIn` | `brand`, `name`, `device`, `location`, `time`, `link` | `mail.newSignIn(to, { device, time, location? })` |
71
73
  | `welcome` | `brand`, `name`, `link` | `mail.welcome(to)` |
72
74
 
73
75
  Every one also gets `locale`, one of `locales`. Every value is a string:
74
76
  `brand` from the options, `name` from the recipient, `link` from `links` —
75
- `links.secureAccount()` for the four notices, `links.recoveryCodes()` (else
77
+ `links.secureAccount()` for the notices other than `recoveryCodeUsed` and
78
+ for `stepUp`, `links.recoveryCodes()` (else
76
79
  `links.secureAccount()`) for `recoveryCodeUsed`, `links.getStarted()` for
77
- `welcome` — `code`, `newEmail` and `when` from the
78
- call, and `expiresIn` derived from the flow's `expiresAt` in the locale, or
80
+ `welcome` — `code`, `newEmail`, `when`, `device` and `time` from the
81
+ call, `location` from the call or `—` without one, and `expiresIn` derived from the flow's `expiresAt` in the locale, or
79
82
  the send's own `expiresIn` ([Sending](sending.md#the-expiry)).
80
83
 
81
84
  `recoveryCodesLeft` is a **sentence**, not the count: "You have 9 recovery
@@ -95,8 +98,8 @@ templates: {
95
98
  // await mail.recoveryCodeUsed(to, { when, recoveryCodesLeft: `Sie haben noch ${left} Wiederherstellungscodes.` });
96
99
  ```
97
100
 
98
- `signInCode` is **never given the challenge**: it is not in its variables,
99
- and the method never reads it. An override states the expiry from
101
+ `signInCode` and `stepUp` are **never given the challenge**: it is not in
102
+ their variables, and the methods never read it. An override states the expiry from
100
103
  `expiresIn`, already formatted:
101
104
 
102
105
  ```ts
@@ -125,11 +128,12 @@ methods live on its prototype and are refused — see
125
128
  [troubleshooting](../troubleshooting.md#janusmail-templatesname-is-not-an-own-enumerable-property--pass-a-plain-object-as--name-variables--rendered-).
126
129
 
127
130
  `templates` is `Partial` while `locales` stays within `en` and `fr`: give
128
- any of the ten, and the defaults render the rest. Once `locales` holds
129
- another locale, it takes **all ten** — see
131
+ any of the twelve, and the defaults render the rest. Once `locales` holds
132
+ another locale, it takes **all twelve** — `stepUp` included, since 0.8, and
133
+ `newSignIn`, since 0.9 — see
130
134
  [Adding a locale](locales.md#adding-a-locale).
131
135
 
132
- `mail.templates` answers the ten in use — yours, and the defaults for the
136
+ `mail.templates` answers the twelve in use — yours, and the defaults for the
133
137
  rest — frozen:
134
138
 
135
139
  ```ts
@@ -151,7 +155,7 @@ const { subject, html, text } = await defaults.resetPassword({
151
155
  });
152
156
  ```
153
157
 
154
- It answers the ten defaults, typed for `'en' | 'fr'`, without a mailer:
158
+ It answers the twelve defaults, typed for `'en' | 'fr'`, without a mailer:
155
159
  to preview an e-mail, to wrap one — add a line to the default text, say — or
156
160
  to send one through something other than `janusMail()`. A default reused for
157
161
  a wider set of locales is a compile error: it could not render the one it
@@ -177,7 +181,7 @@ them renders: that is when the package's `mails/` folder is read, once.
177
181
  ## What the defaults say
178
182
 
179
183
  The defaults are [`@nxgt/mail-presets`](https://www.npmjs.com/package/@nxgt/mail-presets)
180
- 1.0.1, built with a neutral grey theme and no logo:
184
+ 1.1.0, built with a neutral grey theme and no logo:
181
185
 
182
186
  | Template | Built from | Subject (`en`) | Subject (`fr`) |
183
187
  | --- | --- | --- | --- |
@@ -185,25 +189,35 @@ The defaults are [`@nxgt/mail-presets`](https://www.npmjs.com/package/@nxgt/mail
185
189
  | `resetPassword` | `reset-password` | Reset your password | Réinitialisez votre mot de passe |
186
190
  | `signInCode` | `sign-in-code` | Your sign-in code: `042817` | Votre code de connexion : `042817` |
187
191
  | `magicLink` | `magic-link` | Your sign-in link | Votre lien de connexion |
192
+ | `stepUp` | `confirm-action` | Your confirmation code | Votre code de confirmation |
188
193
  | `passwordChanged` | `password-changed` | Your password was changed | Votre mot de passe a été modifié |
189
194
  | `emailChanged` | `email-changed` | Your e-mail address was changed | Votre adresse e-mail a été modifiée |
190
195
  | `twoFactorEnabled` | `two-factor-enabled` | Two-factor authentication was turned on | L'authentification à deux facteurs a été activée |
191
196
  | `twoFactorDisabled` | `two-factor-disabled` | Two-factor authentication was turned off | L'authentification à deux facteurs a été désactivée |
192
197
  | `recoveryCodeUsed` | `recovery-code-used` | A recovery code was used on your account | Un code de récupération a été utilisé sur votre compte |
198
+ | `newSignIn` | `new-sign-in` | New sign-in to your account | Nouvelle connexion à votre compte |
193
199
  | `welcome` | `welcome` | Welcome, `Ada` | Bienvenue, `Ada` |
194
200
 
195
201
  Each has a header and a footer with the brand, a heading, a greeting by name
196
202
  (but `signInCode` and `magicLink`), a button with its link and the link again in text for a
197
- client that shows no button, and a text part. The other four notices add a warning:
198
- *if this was not you, secure your account now*. `recoveryCodeUsed` says it
203
+ client that shows no button, and a text part. The notices other than `recoveryCodeUsed` add a warning:
204
+ *if this was not you, secure your account now*. `stepUp` shows its code as
205
+ the sign-in code does, names no action — *someone, we hope you, asked to do
206
+ something sensitive on your account* — and warns not to share the code if
207
+ it was not the user, its button saying **Secure my account**. `recoveryCodeUsed` says it
199
208
  too, after a warning banner — *a recovery code was used on your account at
200
209
  `when`* — the codes left, and a line on generating new ones; its button says
201
- **Secure my account**. `welcome` has the brand in
210
+ **Secure my account**. `newSignIn` says the account *was signed in from a
211
+ device we had not seen*, then a summary of three lines — **Device**,
212
+ **Location** and **Time**, *Appareil*, *Lieu* and *Heure* in French — then
213
+ *if this was you, there is nothing to do; if this was not you, secure your
214
+ account now*, its button saying **Secure my account**; in the text part each
215
+ line of the summary is its label, then its value on the next line. `welcome` has the brand in
202
216
  its heading — "Welcome to Acme" — and the recipient's name in its subject,
203
217
  filled at send time like the body; its button says **Get started**.
204
218
 
205
- `verifyEmail`, `resetPassword`, `signInCode` and `magicLink` also say how
206
- long the link or the code lasts: "This link expires in 1 hour.", "Ce code expire dans
219
+ `verifyEmail`, `resetPassword`, `signInCode`, `magicLink` and `stepUp` also
220
+ say how long the link or the code lasts: "This link expires in 1 hour.", "Ce code expire dans
207
221
  10 minutes." The text part keeps each paragraph on one line.
208
222
 
209
223
  **The HTML follows dark mode.** Each HTML part declares
@@ -215,7 +229,7 @@ is near-black (`#27272a`) with light text in light mode, and near-white
215
229
  (`#fafafa`) with near-black text (`#18181b`) in dark mode — 17:1 against the
216
230
  dark card, where the light primary would be 1.2:1. The page behind the card
217
231
  is 5% of the light primary over the dark background, a shade darker than the
218
- card, which its border outlines. The sign-in code's box is a light grey
232
+ card, which its border outlines. The code's box — the sign-in code's and the step-up's — is a light grey
219
233
  (`#f1f5f9`) with near-black code (`#020918`) in light mode, and a slate a
220
234
  step above the dark card (`#1e293b`) with light code (`#cbd5e1`) in dark
221
235
  mode, 9.85:1. The muted text — the footer, and the closing *if you did not
package/docs/roadmap.md CHANGED
@@ -9,9 +9,8 @@ Nothing between releases.
9
9
 
10
10
  ## Next
11
11
 
12
- - **The other presets** — `new-sign-in` and `invitation` from
13
- `@nxgt/mail-presets`, each once `@nxgt/janus` has a flow that sends it: a
14
- sign-in from a new device, an invitation to a user type.
12
+ - **The invitation** — `invitation` from `@nxgt/mail-presets`, once
13
+ `@nxgt/janus` has a flow that sends it: an invitation to a user type.
15
14
  - **The other presets of `@nxgt/mail-presets` 0.4.0** — `account-deleted`,
16
15
  after a user is deleted, once `@nxgt/janus` has a deletion a link can
17
16
  undo; and `invitation-accepted`, once it has invitations.
@@ -40,13 +39,33 @@ Nothing between releases.
40
39
  its flows answer what to send, and this package is called with that answer.
41
40
  An application that sends its own way needs neither this package nor a
42
41
  hook to turn off.
43
- - **The challenge in an e-mail** — a sign-in code e-mail carries the code and
44
- never the challenge, not even through an override.
42
+ - **The challenge in an e-mail** — a sign-in code's or a step-up's e-mail
43
+ carries the code and never the challenge, not even through an override.
45
44
 
46
45
  ## Shipped
47
46
 
48
47
  Newest first; from the first release on, the package's CHANGELOG holds every one.
49
48
 
49
+ - **The new sign-in notice, v0.9.0.** `newSignIn(to, { device, time,
50
+ location? })`, built from `@nxgt/mail-presets` 1.1.0's `new-sign-in` in
51
+ English and French — "New sign-in to your account", which device, where
52
+ and when, and a **Secure my account** button to `links.secureAccount()`:
53
+ send it when an `@nxgt/janus` 0.17 sign-in answers `newDevice: true`, or
54
+ on `user.newDeviceSignedIn`. Every value is your text in the recipient's
55
+ locale; `@nxgt/janus` sees no IP, so `location` is yours, and `—` without
56
+ one. Twelve e-mails now, and twelve templates to pass for a locale beyond
57
+ `en` and `fr`.
58
+
59
+ - **The step-up's code, v0.8.0.** `stepUp(issued, to, options?)`, built
60
+ from `@nxgt/mail-presets` 1.1.0's `confirm-action` in English and French:
61
+ send what `@nxgt/janus`'s `auth.stepUp.request(user)` answered, once
62
+ narrowed to `via: 'email'`, to `issued.email` — "Your confirmation code",
63
+ the code, how long it lasts, and a **Secure my account** button to
64
+ `links.secureAccount()` for a user who asked for nothing. A step-up
65
+ confirmed with the user's app sends nothing, and is refused. Eleven
66
+ e-mails now, and eleven templates to pass for a locale beyond `en` and
67
+ `fr`.
68
+
50
69
  - **The sign-in link, v0.7.0.** `magicLink(issued, to?, options?)`, built
51
70
  from `@nxgt/mail-presets` 1.0.1's `magic-link` in English and French:
52
71
  send what `@nxgt/janus` 0.15's `auth.magicLink.request(email)` answered,
@@ -111,23 +130,3 @@ Newest first; from the first release on, the package's CHANGELOG holds every one
111
130
  linking to `links.secureAccount()`: send them on `@nxgt/janus` 0.9's
112
131
  `user.secondFactorEnabled` and `user.secondFactorDisabled` events. Seven
113
132
  e-mails now, and seven templates to pass for a locale beyond `en` and `fr`.
114
- - **Dark mode, v0.3.0.** The e-mails follow the reader's dark mode in
115
- every client that supports it, Gmail excepted: a dark page, a dark card and
116
- light text. Built from `@nxgt/mail-ui` 0.4.0, `@nxgt/mail-presets` 0.4.0
117
- and `@nxgt/mail-i18n` 0.5.0; the text parts, subjects and variables are
118
- unchanged, the manifest is still format 1, and the `@nxgt/mail` peer stays
119
- `>=0.1.0 <1`.
120
-
121
- - **The expiry in the e-mail, v0.2.0.** The verification, reset and sign-in
122
- code e-mails say how long the link or code lasts — "1 hour", "1 heure" —
123
- derived from the flow's `expiresAt` in the recipient's locale, measured
124
- against `janusMail({ clock })` (the clock given to `janus()`), or given per
125
- send as `{ expiresIn }`. Two breaks: a template called directly takes
126
- `expiresIn`, and `signInCode` reads `issued.expiresAt`. Built from
127
- `@nxgt/mail-presets` 0.2.0, whose text parts keep each paragraph on one
128
- line.
129
-
130
- - **The first release, v0.1.0.** The five e-mails of `@nxgt/janus`'s flows,
131
- in English and French, over any `@nxgt/mail` transport: `janusMail()` and
132
- `janusTemplates()`. Built with Maizzle when the package is built, shipped
133
- in `mails/`, and only filled in at send time, every value escaped.
@@ -34,7 +34,7 @@ How the messages are shaped:
34
34
  - [`janusMail: locales holds the same locale twice`](#janusmail-locales-holds-the-same-locale-twice)
35
35
  - [`janusMail: fallbackLocale must be one of locales`](#janusmail-fallbacklocale-must-be-one-of-locales)
36
36
  - [`janusMail: templates must be an object of functions, as { verifyEmail: (variables) => rendered }`](#janusmail-templates-must-be-an-object-of-functions-as--verifyemail-variables--rendered-)
37
- - [`janusMail: templates has no template <name> — name one of verifyEmail, resetPassword, signInCode, magicLink, passwordChanged, emailChanged, twoFactorEnabled, twoFactorDisabled, recoveryCodeUsed, welcome`](#janusmail-templates-has-no-template-name--name-one-of-verifyemail-resetpassword-signincode-magiclink-passwordchanged-emailchanged-twofactorenabled-twofactordisabled-recoverycodeused-welcome)
37
+ - [`janusMail: templates has no template <name> — name one of verifyEmail, resetPassword, signInCode, magicLink, stepUp, passwordChanged, emailChanged, twoFactorEnabled, twoFactorDisabled, recoveryCodeUsed, newSignIn, welcome`](#janusmail-templates-has-no-template-name--name-one-of-verifyemail-resetpassword-signincode-magiclink-stepup-passwordchanged-emailchanged-twofactorenabled-twofactordisabled-recoverycodeused-newsignin-welcome)
38
38
  - [`janusMail: templates.<name> must be a function`](#janusmail-templatesname-must-be-a-function)
39
39
  - [`janusMail: templates.<name> is not an own enumerable property — pass a plain object, as { <name>: (variables) => rendered }`](#janusmail-templatesname-is-not-an-own-enumerable-property--pass-a-plain-object-as--name-variables--rendered-)
40
40
  - [`janusMail: clock must be a Clock — an object with a now function`](#janusmail-clock-must-be-a-clock--an-object-with-a-now-function)
@@ -42,6 +42,7 @@ How the messages are shaped:
42
42
 
43
43
  **Sending**
44
44
  - [`janusMail.magicLink: links.magicLink is missing — pass it to janusMail({ links }) to send sign-in links`](#janusmailmagiclink-linksmagiclink-is-missing--pass-it-to-janusmail-links--to-send-sign-in-links)
45
+ - [`janusMail.stepUp: via must be 'email' — a step-up confirmed with the user's app sends no e-mail`](#janusmailstepup-via-must-be-email--a-step-up-confirmed-with-the-users-app-sends-no-e-mail)
45
46
  - [`janusMail.<method>: <field> must be a string`](#janusmailmethod-field-must-be-a-string)
46
47
  - [`janusMail.<method>: expiresAt must be a Date`](#janusmailmethod-expiresat-must-be-a-date)
47
48
  - [`janusMail.recoveryCodeUsed: recoveryCodesLeft must be a count — a whole number, 0 or more — or the sentence to show`](#janusmailrecoverycodeused-recoverycodesleft-must-be-a-count--a-whole-number-0-or-more--or-the-sentence-to-show)
@@ -68,10 +69,14 @@ How the messages are shaped:
68
69
  - [`TS2739: Type '{ … }' is missing the following properties from type 'JanusMailTemplates<…>'`](#ts2739-type----is-missing-the-following-properties-from-type-janusmailtemplates)
69
70
  - [`TS2741: Property 'recoveryCodeUsed' is missing in type '{ … }' but required in type 'JanusMailTemplates<…>'.`](#ts2741-property-recoverycodeused-is-missing-in-type----but-required-in-type-janusmailtemplates)
70
71
  - [`TS2741: Property 'magicLink' is missing in type '{ … }' but required in type 'JanusMailTemplates<…>'.`](#ts2741-property-magiclink-is-missing-in-type----but-required-in-type-janusmailtemplates)
72
+ - [`TS2741: Property 'stepUp' is missing in type '{ … }' but required in type 'JanusMailTemplates<…>'.`](#ts2741-property-stepup-is-missing-in-type----but-required-in-type-janusmailtemplates)
73
+ - [`TS2741: Property 'newSignIn' is missing in type '{ … }' but required in type 'JanusMailTemplates<…>'.`](#ts2741-property-newsignin-is-missing-in-type----but-required-in-type-janusmailtemplates)
74
+ - [`TS2345: Argument of type 'StepUpByEmail<…> | StepUpByApp<…>' is not assignable to parameter of type 'Pick<StepUpByEmail<unknown>, …>'.`](#ts2345-argument-of-type-stepupbyemail--stepupbyapp-is-not-assignable-to-parameter-of-type-pickstepupbyemailunknown-)
71
75
  - [`TS2322: Type '"de"' is not assignable to type '"en" | "fr"'.`](#ts2322-type-de-is-not-assignable-to-type-en--fr)
72
76
  - [`TS2741: Property 'getStarted' is missing in type '{ … }' but required in type 'JanusMailLinks'.`](#ts2741-property-getstarted-is-missing-in-type----but-required-in-type-janusmaillinks)
73
77
  - [`TS2322: Type 'number | null' is not assignable to type 'string | number'.`](#ts2322-type-number--null-is-not-assignable-to-type-string--number)
74
78
  - [`TS2322: Type 'Date' is not assignable to type 'string'.`](#ts2322-type-date-is-not-assignable-to-type-string)
79
+ - [`TS2322: Type 'string | null' is not assignable to type 'string'.`, on `location`](#ts2322-type-string--null-is-not-assignable-to-type-string-on-location) — or `… to type 'string | undefined'.`
75
80
 
76
81
  ---
77
82
 
@@ -196,9 +201,9 @@ A `fallbackLocale` the list does not hold — `'de'` with the default
196
201
 
197
202
  `templates` given as a list or a function. Key each template by its name.
198
203
 
199
- ### `janusMail: templates has no template <name> — name one of verifyEmail, resetPassword, signInCode, magicLink, passwordChanged, emailChanged, twoFactorEnabled, twoFactorDisabled, recoveryCodeUsed, welcome`
204
+ ### `janusMail: templates has no template <name> — name one of verifyEmail, resetPassword, signInCode, magicLink, stepUp, passwordChanged, emailChanged, twoFactorEnabled, twoFactorDisabled, recoveryCodeUsed, newSignIn, welcome`
200
205
 
201
- A key that is not one of the ten — `newSignIn`, or the e-mail's file name
206
+ A key that is not one of the twelve — `invitation`, or the e-mail's file name
202
207
  `'verify-email'` rather than the template's, `verifyEmail`. Other e-mails are
203
208
  on the [roadmap](roadmap.md); send them with `@nxgt/mail` directly meanwhile.
204
209
 
@@ -232,7 +237,7 @@ as it is: fields are own properties.
232
237
  ### `janusMail: the default templates are built in en and fr only — with another locale in locales, pass every template in templates; <names> missing`
233
238
 
234
239
  `locales` holds a locale beyond `en` and `fr`, and `templates` leaves some
235
- out: the defaults could not render them in that locale. Pass all ten — see
240
+ out: the defaults could not render them in that locale. Pass all twelve — see
236
241
  [Adding a locale](guide/locales.md#adding-a-locale) — or drop the locale.
237
242
  The message lists the templates missing.
238
243
 
@@ -267,6 +272,26 @@ const mail = janusMail({
267
272
 
268
273
  Sending again fails again: it is a bug, not an outage.
269
274
 
275
+ ### `janusMail.stepUp: via must be 'email' — a step-up confirmed with the user's app sends no e-mail`
276
+
277
+ A `TypeError` from `mail.stepUp`, before anything is rendered or sent: it
278
+ was given what `auth.stepUp.request(user)` answered for a user whose second
279
+ factor is active — `via: 'secondFactor'`, with no code and no address — or
280
+ something with no `via` at all, such as a sign-in code's answer. A step-up
281
+ confirmed with the user's app sends nothing: ask for the code their app
282
+ shows. Check `via` first:
283
+
284
+ ```ts
285
+ const issued = await auth.stepUp.request(current.user);
286
+ if (issued.via === 'email') {
287
+ await mail.stepUp(issued, { name: current.user.name, locale: current.user.locale });
288
+ }
289
+ return Response.json({ challenge: issued.challenge, via: issued.via });
290
+ ```
291
+
292
+ In TypeScript the unchecked call does not compile — see
293
+ [`TS2345: Argument of type 'StepUpByEmail<…> | StepUpByApp<…>' …`](#ts2345-argument-of-type-stepupbyemail--stepupbyapp-is-not-assignable-to-parameter-of-type-pickstepupbyemailunknown-).
294
+
270
295
  ### `janusMail.<method>: <field> must be a string`
271
296
 
272
297
  A `TypeError` from a call, naming the method and the field it could not read
@@ -286,7 +311,9 @@ send it with `signInCode`, or ask `auth.magicLink.request` for a link.
286
311
 
287
312
  `issued` needs `token` and `email` (`code` and `email` for `signInCode`), and
288
313
  the recipient its `name` — a user whose schema has no name passes one
289
- anyway: `{ name: user.email }`. `janusMail.verifyEmail: expiresIn must be a
314
+ anyway: `{ name: user.email }`. `janusMail.stepUp: name must be a string`
315
+ is `stepUp` called without the recipient: unlike `signInCode`, its e-mail
316
+ greets the user by name. `janusMail.verifyEmail: expiresIn must be a
290
317
  string` is the third argument's `expiresIn` given as something else — a
291
318
  number of seconds: pass the text to show, `{ expiresIn: '1 hour' }`, or
292
319
  leave it out.
@@ -315,6 +342,14 @@ if (event.type === 'user.emailChanged' && event.formerEmail != null) {
315
342
  }
316
343
  ```
317
344
 
345
+ `janusMail.newSignIn: device must be a string` and `janusMail.newSignIn:
346
+ time must be a string` are the second argument missing, or its `time` given
347
+ as the session's `Date`: pass the device as you describe it and the time as
348
+ text. `janusMail.newSignIn: location must be a string` is a location lookup's
349
+ `null`: leave `location` out, and the e-mail shows `—`. `janusMail.newSignIn:
350
+ email must be a string` is the `user.newDeviceSignedIn` event itself given
351
+ as the recipient — read the user first.
352
+
318
353
  `janusMail.recoveryCodeUsed: when must be a string` is the second argument
319
354
  missing, or its `when` given as the event's `Date`: pass the text to show,
320
355
  formatted in the recipient's locale and time zone —
@@ -322,8 +357,8 @@ formatted in the recipient's locale and time zone —
322
357
 
323
358
  ### `janusMail.<method>: expiresAt must be a Date`
324
359
 
325
- A `TypeError` from `verifyEmail`, `resetPassword`, `signInCode` or
326
- `magicLink`: the e-mail says how long its link or code lasts, from `issued.expiresAt`, and that is
360
+ A `TypeError` from `verifyEmail`, `resetPassword`, `signInCode`,
361
+ `magicLink` or `stepUp`: the e-mail says how long its link or code lasts, from `issued.expiresAt`, and that is
327
362
  not a valid `Date`. The usual cause is a flow's answer that went through
328
363
  JSON — a job queue, a cache — where a `Date` becomes a string. Nothing is
329
364
  sent. Revive it when the job runs, or say the expiry yourself:
@@ -363,8 +398,8 @@ await mail.recoveryCodeUsed(to, { when, recoveryCodesLeft: `Sie haben noch ${lef
363
398
 
364
399
  ### `janusMail.<method>: clock.now() must answer a Date`
365
400
 
366
- A `TypeError` from `verifyEmail`, `resetPassword`, `signInCode` or
367
- `magicLink`: the `clock` given to `janusMail()` has a `now` that answered something else — a
401
+ A `TypeError` from `verifyEmail`, `resetPassword`, `signInCode`,
402
+ `magicLink` or `stepUp`: the `clock` given to `janusMail()` has a `now` that answered something else — a
368
403
  number from `Date.now()`, a string, an invalid `Date`. Nothing is sent.
369
404
  `clock` is `@nxgt/janus`'s `Clock`: pass the one `janus({ clock })` was
370
405
  given, or `{ now: () => new Date() }`.
@@ -497,7 +532,7 @@ avoids the read altogether. (In this repository: run `bun run build`.)
497
532
 
498
533
  The default templates read `mails/` with `node:fs`, through
499
534
  `@nxgt/mail/renderer`: Node, Bun or Deno only. On an edge runtime, pass all
500
- ten templates yourself; an inlined build is on the [roadmap](roadmap.md).
535
+ twelve templates yourself; an inlined build is on the [roadmap](roadmap.md).
501
536
 
502
537
  ### An e-mail stays light in dark mode
503
538
 
@@ -630,8 +665,9 @@ whole.
630
665
  ### `TS2739: Type '{ … }' is missing the following properties from type 'JanusMailTemplates<…>'`
631
666
 
632
667
  `locales` holds a locale beyond `en` and `fr`, and `templates` does not give
633
- all ten. With five or more missing, the code is `TS2740` and the list ends
634
- `…, and 5 more.` See [Adding a locale](guide/locales.md#adding-a-locale).
668
+ all twelve. With five or more missing, the code is `TS2740` and the list ends
669
+ `…, and <n> more.` — `…, and 7 more.` for a `templates` holding one
670
+ template. See [Adding a locale](guide/locales.md#adding-a-locale).
635
671
 
636
672
  ### `TS2741: Property 'recoveryCodeUsed' is missing in type '{ … }' but required in type 'JanusMailTemplates<…>'.`
637
673
 
@@ -651,6 +687,42 @@ Add `magicLink` — it is given `brand`, `link` and `expiresIn` — even if you
651
687
  never send it; in JavaScript the same `templates` is
652
688
  [`janusMail: the default templates are built in en and fr only — …; magicLink missing`](#janusmail-the-default-templates-are-built-in-en-and-fr-only--with-another-locale-in-locales-pass-every-template-in-templates-names-missing).
653
689
 
690
+ ### `TS2741: Property 'stepUp' is missing in type '{ … }' but required in type 'JanusMailTemplates<…>'.`
691
+
692
+ `templates` written before 0.8, with a locale beyond `en` and `fr`: since
693
+ 0.8.0 there are eleven templates, and with such a locale every one is
694
+ yours. Add `stepUp` — it is given `brand`, `name`, `code`, `expiresIn` and
695
+ `link` — even if you never send it; in JavaScript the same `templates` is
696
+ [`janusMail: the default templates are built in en and fr only — …; stepUp missing`](#janusmail-the-default-templates-are-built-in-en-and-fr-only--with-another-locale-in-locales-pass-every-template-in-templates-names-missing).
697
+ See [Templates](guide/templates.md#what-each-template-is-given).
698
+
699
+ ### `TS2741: Property 'newSignIn' is missing in type '{ … }' but required in type 'JanusMailTemplates<…>'.`
700
+
701
+ `templates` written before 0.9, with a locale beyond `en` and `fr`: since
702
+ 0.9.0 there are twelve templates, and with such a locale every one is
703
+ yours. Add `newSignIn` — it is given `brand`, `name`, `device`, `location`,
704
+ `time` and `link` — even if you never send it; in JavaScript the same
705
+ `templates` is
706
+ [`janusMail: the default templates are built in en and fr only — …; newSignIn missing`](#janusmail-the-default-templates-are-built-in-en-and-fr-only--with-another-locale-in-locales-pass-every-template-in-templates-names-missing).
707
+ See [Templates](guide/templates.md#what-each-template-is-given).
708
+
709
+ ### `TS2345: Argument of type 'StepUpByEmail<…> | StepUpByApp<…>' is not assignable to parameter of type 'Pick<StepUpByEmail<unknown>, …>'.`
710
+
711
+ What `auth.stepUp.request(user)` answered, passed to `mail.stepUp` without
712
+ checking `via`, on a user type that may have a second factor. The error
713
+ goes on: `Type 'StepUpByApp<…>' is missing the following properties …`,
714
+ naming `code` and `email` — a user whose factor is active confirms with their app, and
715
+ there is nothing to send. Narrow it first:
716
+
717
+ ```ts
718
+ const issued = await auth.stepUp.request(current.user);
719
+ if (issued.via === 'email') await mail.stepUp(issued, { name: current.user.name });
720
+ ```
721
+
722
+ A sign-in code's answer given to `stepUp` reads `Property 'via' is missing
723
+ in type 'IssuedCode<…>'`: a sign-in code signs in, it confirms no action —
724
+ send it with `signInCode`.
725
+
654
726
  ### `TS2322: Type '"de"' is not assignable to type '"en" | "fr"'.`
655
727
 
656
728
  A `fallbackLocale` outside `locales`. Add it to `locales` — with every
@@ -681,10 +753,24 @@ has no codes to count; check it first, as in
681
753
 
682
754
  ### `TS2322: Type 'Date' is not assignable to type 'string'.`
683
755
 
684
- On `when`: the event's `occurredAt` passed as it is. `when` is the text the
685
- e-mail shows — format it in the recipient's locale and time zone first:
756
+ On `when`: the event's `occurredAt` passed as it is — or, on `newSignIn`'s
757
+ `time`, the session's `createdAt`. Both are the text the e-mail shows —
758
+ format the date in the recipient's locale and time zone first:
686
759
 
687
760
  ```ts
688
761
  const when = new Intl.DateTimeFormat(user.locale, { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris' })
689
762
  .format(event.occurredAt);
690
763
  ```
764
+
765
+ ### `TS2322: Type 'string | null' is not assignable to type 'string'.`, on `location`
766
+
767
+ Also, without `exactOptionalPropertyTypes` in your tsconfig:
768
+ `Type 'string | null' is not assignable to type 'string | undefined'.`
769
+
770
+ On `newSignIn`'s `location`: a geo-IP lookup's answer, `null` when it found
771
+ nothing, passed as it is. `location` is optional: leave it out, and the
772
+ e-mail shows `—`:
773
+
774
+ ```ts
775
+ await mail.newSignIn(to, { device, time, ...(city === null ? {} : { location: city }) });
776
+ ```