@nxgt/janus-mail 0.1.0 → 0.2.1

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 (43) hide show
  1. package/README.md +63 -17
  2. package/dist/expiring-mails.d.ts +7 -0
  3. package/dist/expiring-mails.d.ts.map +1 -0
  4. package/dist/expiry.d.ts +29 -0
  5. package/dist/expiry.d.ts.map +1 -0
  6. package/dist/generated/mail.d.ts +3 -0
  7. package/dist/generated/mail.d.ts.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +152 -84
  11. package/dist/index.js.map +10 -6
  12. package/dist/janus-mail.d.ts +6 -0
  13. package/dist/janus-mail.d.ts.map +1 -1
  14. package/dist/notice-mails.d.ts +12 -0
  15. package/dist/notice-mails.d.ts.map +1 -0
  16. package/dist/options.d.ts +2 -0
  17. package/dist/options.d.ts.map +1 -1
  18. package/dist/send.d.ts +27 -0
  19. package/dist/send.d.ts.map +1 -0
  20. package/dist/templates.d.ts.map +1 -1
  21. package/dist/types.d.ts +36 -7
  22. package/dist/types.d.ts.map +1 -1
  23. package/docs/README.md +2 -1
  24. package/docs/guide/building.md +23 -1
  25. package/docs/guide/locales.md +10 -4
  26. package/docs/guide/sending.md +65 -11
  27. package/docs/guide/templates.md +16 -14
  28. package/docs/roadmap.md +9 -2
  29. package/docs/troubleshooting.md +98 -4
  30. package/mails/en/reset-password.html +2 -2
  31. package/mails/en/reset-password.txt +3 -2
  32. package/mails/en/sign-in-code.html +1 -1
  33. package/mails/en/sign-in-code.txt +2 -0
  34. package/mails/en/verify-email.html +2 -2
  35. package/mails/en/verify-email.txt +3 -2
  36. package/mails/fr/reset-password.html +2 -2
  37. package/mails/fr/reset-password.txt +3 -2
  38. package/mails/fr/sign-in-code.html +1 -1
  39. package/mails/fr/sign-in-code.txt +2 -0
  40. package/mails/fr/verify-email.html +2 -2
  41. package/mails/fr/verify-email.txt +3 -2
  42. package/mails/mail-manifest.json +4 -1
  43. package/package.json +6 -6
@@ -48,6 +48,7 @@ time one is sent, and kept.
48
48
  | `locales` | `readonly L[]` | `['en', 'fr']` | The locales sent in — see [Locales](locales.md) |
49
49
  | `fallbackLocale` | one of `locales` | `'en'`, else the first of `locales` | The locale when the recipient wants none of `locales` |
50
50
  | `templates` | `Partial<JanusMailTemplates<L>>` | none | Your own templates, over the defaults — see [Templates](templates.md) |
51
+ | `clock` | `Clock` from `@nxgt/janus` | the system clock | What the time left until a flow's `expiresAt` is measured against — pass the one `janus({ clock })` was given; see [The expiry](#the-expiry) |
51
52
 
52
53
  Every link is called at send time, and must answer an absolute `http:`,
53
54
  `https:` or `mailto:` URL: a default template refuses anything else with
@@ -72,16 +73,17 @@ option and never its value — every message is in
72
73
  Each method renders its e-mail with its template, hands it to the mailer,
73
74
  and answers the mailer's `SentMail` (`{ messageId }`).
74
75
 
75
- ### `verifyEmail(issued, to)`
76
+ ### `verifyEmail(issued, to, options?)`
76
77
 
77
78
  ```ts
78
79
  const issued = await auth.verifyEmail.send(user); // IssuedToken: { token, email, expiresAt }
79
80
  await mail.verifyEmail(issued, { name: user.name, locale: user.locale });
80
81
  ```
81
82
 
82
- Sent to `issued.email`, with the link `links.verifyEmail(issued.token)`.
83
+ Sent to `issued.email`, with the link `links.verifyEmail(issued.token)` and
84
+ how long it lasts — see [The expiry](#the-expiry).
83
85
 
84
- ### `resetPassword(issued, to)`
86
+ ### `resetPassword(issued, to, options?)`
85
87
 
86
88
  ```ts
87
89
  const issued = await auth.resetPassword.request(email); // (IssuedToken & { user }) | null
@@ -92,16 +94,18 @@ if (issued !== null) {
92
94
 
93
95
  `request` answers `null` for an address nobody holds, and the compiler
94
96
  refuses `null` here: check it first, and answer the visitor the same either
95
- way. Sent to `issued.email`, with `links.resetPassword(issued.token)`.
97
+ way. Sent to `issued.email`, with `links.resetPassword(issued.token)` and how
98
+ long it lasts.
96
99
 
97
- ### `signInCode(issued, to?)`
100
+ ### `signInCode(issued, to?, options?)`
98
101
 
99
102
  ```ts
100
103
  const issued = await auth.signInCode.request(email); // IssuedCode: { code, challenge, email, expiresAt, user } | null
101
104
  if (issued !== null) await mail.signInCode(issued, { locale: issued.user.locale });
102
105
  ```
103
106
 
104
- It reads `issued.code` and `issued.email`, **and nothing else**: the
107
+ It reads `issued.code`, `issued.email` and `issued.expiresAt`, **and nothing
108
+ else**: the
105
109
  challenge is the visitor's secret, and neither the default template nor an
106
110
  override is ever given it. `to` is optional — the e-mail greets nobody by
107
111
  name — and only its `locale` is read.
@@ -130,6 +134,50 @@ since whoever changed it already controls the new one. It names `newEmail`,
130
134
  and links to `links.secureAccount()`. Read the former address before the
131
135
  update: the user `update` answers already holds the new one.
132
136
 
137
+ ## The expiry
138
+
139
+ `verifyEmail`, `resetPassword` and `signInCode` say how long the link or the
140
+ code lasts — "This link expires in 1 hour.", "Ce code expire dans
141
+ 10 minutes." The text is `expiresIn`, one of the template's variables,
142
+ derived at send time from the flow's `issued.expiresAt`:
143
+
144
+ 1. the time left until `expiresAt`, rounded to the minute — the flow set it a
145
+ moment ago, so an hour is still "1 hour";
146
+ 2. then **down** to the largest whole unit it holds — days, hours or minutes
147
+ — so, at 30 seconds or more, the e-mail never promises more than half a
148
+ minute beyond what is left; under that, it says "1 minute". 90 minutes is
149
+ "1 hour", 36 hours "1 day";
150
+ 3. formatted by `Intl.NumberFormat` with `style: 'unit'` and
151
+ `unitDisplay: 'long'`, in the recipient's locale: "3 hours", "3 heures".
152
+
153
+ With `@nxgt/janus`'s defaults, a verification link says "1 day", a reset
154
+ link "1 hour" and a sign-in code "10 minutes".
155
+
156
+ The time is `janusMail({ clock })`'s — pass the clock `janus({ clock })` was
157
+ given, a `fixedClock` in tests — else the system clock:
158
+
159
+ ```ts
160
+ import { fixedClock } from '@nxgt/janus';
161
+
162
+ const clock = fixedClock(Date.UTC(2026, 0, 1));
163
+ const auth = janus({ /* … */ clock });
164
+ const mail = janusMail({ mailer, from, brand: 'Acme', links, clock });
165
+ ```
166
+
167
+ To say it yourself — another wording, or a locale the runtime's `Intl` has no
168
+ data for — pass `expiresIn` as the third argument, plain text already in the
169
+ recipient's language. `expiresAt` is then not read:
170
+
171
+ ```ts
172
+ await mail.verifyEmail(issued, { name: user.name, locale: 'fr' }, { expiresIn: '24 heures' });
173
+ await mail.signInCode(issued, undefined, { expiresIn: 'ten minutes' });
174
+ ```
175
+
176
+ A send whose `expiresAt` is not a valid `Date` — a flow's answer that went
177
+ through JSON on its way to a queue holds a string — or is already past, is
178
+ a `TypeError`, and nothing reaches the mailer: revive the date with
179
+ `new Date(issued.expiresAt)` when the job runs, or pass `expiresIn`.
180
+
133
181
  ## Where each e-mail goes
134
182
 
135
183
  | Method | To | Never to |
@@ -157,7 +205,7 @@ defines no error class and wraps nothing:
157
205
  | --- | --- | --- | --- |
158
206
  | `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
207
  | `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 |
208
+ | `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 |
161
209
  | `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
210
 
163
211
  `instanceof` holds against the classes of your own `@nxgt/mail`, since it is
@@ -197,9 +245,14 @@ fail:
197
245
 
198
246
  ```ts
199
247
  import { expect, it } from 'bun:test';
248
+ import { fixedClock } from '@nxgt/janus';
200
249
  import { createMemoryMailer, MailFailure } from '@nxgt/mail';
201
250
  import { janusMail } from '@nxgt/janus-mail';
202
251
 
252
+ // The clock given to janus({ clock }) too: the codes it issues expire ten minutes on.
253
+ const clock = fixedClock(Date.UTC(2026, 0, 1, 9));
254
+ const expiresAt = new Date(Date.UTC(2026, 0, 1, 9, 10));
255
+
203
256
  const links = {
204
257
  verifyEmail: (token: string) => `https://acme.example/verify?token=${token}`,
205
258
  resetPassword: (token: string) => `https://acme.example/reset?token=${token}`,
@@ -208,24 +261,25 @@ const links = {
208
261
 
209
262
  it('sends the code, and never the challenge', async () => {
210
263
  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' };
264
+ const mail = janusMail({ mailer, from: 'noreply@acme.example', brand: 'Acme', links, clock });
265
+ const issued = { code: '042817', challenge: 'secret', email: 'ada@example.com', expiresAt };
213
266
 
214
267
  await mail.signInCode(issued, { locale: 'fr-CA' });
215
268
 
216
269
  const [sent] = mailer.sent;
217
270
  expect(sent?.to).toBe('ada@example.com');
218
271
  expect(sent?.subject).toBe('Votre code de connexion : 042817');
272
+ expect(sent?.text).toContain('10 minutes.');
219
273
  expect(`${sent?.html}${sent?.text}`).not.toContain('secret');
220
274
  });
221
275
 
222
276
  it('says so when the mailer is down', async () => {
223
277
  const mailer = createMemoryMailer();
224
278
  mailer.failNext();
225
- const mail = janusMail({ mailer, from: 'noreply@acme.example', brand: 'Acme', links });
279
+ const mail = janusMail({ mailer, from: 'noreply@acme.example', brand: 'Acme', links, clock });
226
280
 
227
281
  const error = await mail
228
- .signInCode({ code: '042817', email: 'ada@example.com' })
282
+ .signInCode({ code: '042817', email: 'ada@example.com', expiresAt })
229
283
  .then(() => null, (e: unknown) => e); // settled where it is created
230
284
 
231
285
  expect(error).toBeInstanceOf(MailFailure);
@@ -59,30 +59,30 @@ e-mail.
59
59
 
60
60
  | Template | Variables | Sent by |
61
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?)` |
62
+ | `verifyEmail` | `brand`, `name`, `link`, `expiresIn` | `mail.verifyEmail(issued, to, options?)` |
63
+ | `resetPassword` | `brand`, `name`, `link`, `expiresIn` | `mail.resetPassword(issued, to, options?)` |
64
+ | `signInCode` | `brand`, `code`, `expiresIn` | `mail.signInCode(issued, to?, options?)` |
65
65
  | `passwordChanged` | `brand`, `name`, `link` | `mail.passwordChanged(to)` |
66
66
  | `emailChanged` | `brand`, `name`, `link`, `newEmail` | `mail.emailChanged(to)` |
67
67
 
68
68
  Every one also gets `locale`, one of `locales`. Every value is a string:
69
69
  `brand` from the options, `name` from the recipient, `link` from `links` —
70
70
  `links.secureAccount()` for the two notices — `code` and `newEmail` from the
71
- call.
71
+ call, and `expiresIn` derived from the flow's `expiresAt` in the locale, or
72
+ the send's own `expiresIn` ([Sending](sending.md#the-expiry)).
72
73
 
73
74
  `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:
75
+ and the method never reads it. An override states the expiry from
76
+ `expiresIn`, already formatted:
76
77
 
77
78
  ```ts
78
79
  const mail = janusMail({
79
80
  mailer, from, brand: 'Acme', links,
80
81
  templates: {
81
- // The code lives ten minutes, @nxgt/janus's default for tokens.signInCode.
82
- signInCode: ({ code, locale }) =>
82
+ signInCode: ({ code, expiresIn, locale }) =>
83
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.` },
84
+ ? { subject: `Code : ${code}`, html: `<p>${code} — valable ${expiresIn}.</p>`, text: `${code} — valable ${expiresIn}.` }
85
+ : { subject: `Code: ${code}`, html: `<p>${code} — valid for ${expiresIn}.</p>`, text: `${code} — valid for ${expiresIn}.` },
86
86
  },
87
87
  });
88
88
  ```
@@ -109,7 +109,7 @@ another locale, it takes **all five** — see
109
109
  rest — frozen:
110
110
 
111
111
  ```ts
112
- const rendered = await mail.templates.verifyEmail({ brand: 'Acme', name: 'Ada', link, locale: 'fr' });
112
+ const rendered = await mail.templates.verifyEmail({ brand: 'Acme', name: 'Ada', link, expiresIn: '1 heure', locale: 'fr' });
113
113
  ```
114
114
 
115
115
  ## The defaults alone — `janusTemplates()`
@@ -122,6 +122,7 @@ const { subject, html, text } = await defaults.resetPassword({
122
122
  brand: 'Acme',
123
123
  name: 'Ada',
124
124
  link: 'https://acme.example/reset?token=abc',
125
+ expiresIn: '1 heure',
125
126
  locale: 'fr',
126
127
  });
127
128
  ```
@@ -152,7 +153,7 @@ them renders: that is when the package's `mails/` folder is read, once.
152
153
  ## What the defaults say
153
154
 
154
155
  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
+ 0.3.0, built with a neutral grey theme and no logo:
156
157
 
157
158
  | Template | Built from | Subject (`en`) | Subject (`fr`) |
158
159
  | --- | --- | --- | --- |
@@ -167,8 +168,9 @@ Each has a header and a footer with the brand, a heading, a greeting by name
167
168
  client that shows no button, and a text part. The two notices add a warning:
168
169
  *if this was not you, secure your account now*.
169
170
 
170
- **They do not state how long a link or a code lasts.** Replace the template
171
- when yours must: see above.
171
+ `verifyEmail`, `resetPassword` and `signInCode` also say how long the link
172
+ or the code lasts: "This link expires in 1 hour.", "Ce code expire dans
173
+ 10 minutes." The text part keeps each paragraph on one line.
172
174
 
173
175
  **The brand is text.** It is written in the header, the body and the footer,
174
176
  escaped — no logo and no link, which would need absolute URLs known when the
package/docs/roadmap.md CHANGED
@@ -13,8 +13,6 @@ Nothing between releases.
13
13
  `invitation` from `@nxgt/mail-presets`, each once `@nxgt/janus` has a flow
14
14
  that sends it: a sign-in link, a sign-in from a new device, a user created,
15
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
16
 
19
17
  ## Later
20
18
 
@@ -46,6 +44,15 @@ Nothing between releases.
46
44
 
47
45
  Newest first; from the first release on, the package's CHANGELOG holds every one.
48
46
 
47
+ - **The expiry in the e-mail, v0.2.0.** The verification, reset and sign-in
48
+ code e-mails say how long the link or code lasts — "1 hour", "1 heure" —
49
+ derived from the flow's `expiresAt` in the recipient's locale, measured
50
+ against `janusMail({ clock })` (the clock given to `janus()`), or given per
51
+ send as `{ expiresIn }`. Two breaks: a template called directly takes
52
+ `expiresIn`, and `signInCode` reads `issued.expiresAt`. Built from
53
+ `@nxgt/mail-presets` 0.2.0, whose text parts keep each paragraph on one
54
+ line.
55
+
49
56
  - **The first release, v0.1.0.** The five e-mails of `@nxgt/janus`'s flows,
50
57
  in English and French, over any `@nxgt/mail` transport: `janusMail()` and
51
58
  `janusTemplates()`. Built with Maizzle when the package is built, shipped
@@ -1,7 +1,7 @@
1
1
  # Troubleshooting `@nxgt/janus-mail`
2
2
 
3
- Each entry is headed by the text you see: a message, an error `code`, or a
4
- compiler error. Search this page for the words of your message.
3
+ Each entry is headed by the text you see: a message, an error `code`, a
4
+ compiler error — or, where nothing is thrown, what the e-mail says. Search this page for the words of your message.
5
5
 
6
6
  How the messages are shaped:
7
7
 
@@ -28,16 +28,22 @@ How the messages are shaped:
28
28
  - [`janusMail: links must be an object, as { verifyEmail, resetPassword, secureAccount }`](#janusmail-links-must-be-an-object-as--verifyemail-resetpassword-secureaccount-)
29
29
  - [`janusMail: links.<name> must be a function`](#janusmail-linksname-must-be-a-function)
30
30
  - [`janusMail: locales must list at least one locale, as ['en', 'fr']`](#janusmail-locales-must-list-at-least-one-locale-as-en-fr)
31
+ - [`janusMail: locales must be BCP 47 language tags, as 'fr-CA'`](#janusmail-locales-must-be-bcp-47-language-tags-as-fr-ca)
31
32
  - [`janusMail: locales holds the same locale twice`](#janusmail-locales-holds-the-same-locale-twice)
32
33
  - [`janusMail: fallbackLocale must be one of locales`](#janusmail-fallbacklocale-must-be-one-of-locales)
33
34
  - [`janusMail: templates must be an object of functions, as { verifyEmail: (variables) => rendered }`](#janusmail-templates-must-be-an-object-of-functions-as--verifyemail-variables--rendered-)
34
35
  - [`janusMail: templates has no template <name> — name one of verifyEmail, resetPassword, signInCode, passwordChanged, emailChanged`](#janusmail-templates-has-no-template-name--name-one-of-verifyemail-resetpassword-signincode-passwordchanged-emailchanged)
35
36
  - [`janusMail: templates.<name> must be a function`](#janusmail-templatesname-must-be-a-function)
36
37
  - [`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-)
38
+ - [`janusMail: clock must be a Clock — an object with a now function`](#janusmail-clock-must-be-a-clock--an-object-with-a-now-function)
37
39
  - [`janusMail: the default templates are built in en and fr only — with another locale in locales, pass every template in templates; <names> missing`](#janusmail-the-default-templates-are-built-in-en-and-fr-only--with-another-locale-in-locales-pass-every-template-in-templates-names-missing)
38
40
 
39
41
  **Sending**
40
42
  - [`janusMail.<method>: <field> must be a string`](#janusmailmethod-field-must-be-a-string)
43
+ - [`janusMail.<method>: expiresAt must be a Date`](#janusmailmethod-expiresat-must-be-a-date)
44
+ - [`janusMail.<method>: clock.now() must answer a Date`](#janusmailmethod-clocknow-must-answer-a-date)
45
+ - [`janusMail.<method>: expiresAt is past — the link or code would not work`](#janusmailmethod-expiresat-is-past--the-link-or-code-would-not-work)
46
+ - [`This link expires in 1 hour.` — in the wrong language, or shorter than the `tokens` TTL](#this-link-expires-in-1-hour--in-the-wrong-language-or-shorter-than-the-tokens-ttl)
41
47
  - [`MAIL_REFUSED` — `render: <email>: link must be an http:, https: or mailto: URL`](#mail_refused--render-email-link-must-be-an-http-https-or-mailto-url)
42
48
  - [`janusMail.<method>: links.<call> must answer a string`](#janusmailmethod-linkscall-must-answer-a-string)
43
49
  - [`render: <email>: link must be a string or a finite number`](#render-email-link-must-be-a-string-or-a-finite-number)
@@ -48,6 +54,7 @@ How the messages are shaped:
48
54
 
49
55
  **Compile errors**
50
56
  - [`TS2345: Argument of type '(IssuedToken & { user: … }) | null' is not assignable to parameter of type 'IssuedToken'.`](#ts2345-argument-of-type-issuedtoken---user----null-is-not-assignable-to-parameter-of-type-issuedtoken)
57
+ - [`TS2345: … Types of property 'expiresAt' are incompatible. Type 'string' is not assignable to type 'Date'.`](#ts2345--types-of-property-expiresat-are-incompatible-type-string-is-not-assignable-to-type-date)
51
58
  - [`TS2739: Type '{ … }' is missing the following properties from type 'JanusMailTemplates<…>'`](#ts2739-type----is-missing-the-following-properties-from-type-janusmailtemplates)
52
59
  - [`TS2322: Type '"de"' is not assignable to type '"en" | "fr"'.`](#ts2322-type-de-is-not-assignable-to-type-en--fr)
53
60
 
@@ -115,6 +122,19 @@ links: {
115
122
  `locales: []`, `locales: 'en'`, or a list holding something that is not a
116
123
  non-empty string. Leave it out for `['en', 'fr']`.
117
124
 
125
+ ### `janusMail: locales must be BCP 47 language tags, as 'fr-CA'`
126
+
127
+ A `TypeError` when `janusMail()` is called: a locale in `locales` is one
128
+ `Intl` refuses — `de_DE` with an underscore, a grandfathered `i-klingon`, a
129
+ bare private-use `x-…`. The expiry is formatted by `Intl` in the locale
130
+ picked, so a locale it cannot parse would fail at the first send; it is
131
+ refused here instead. Write a tag `Intl` takes — `de-DE`, not `de_DE`; `tlh`,
132
+ not `i-klingon`:
133
+
134
+ ```ts
135
+ janusMail({ mailer, from, brand: 'Acme', links, locales: ['en', 'fr', 'de-DE'], templates });
136
+ ```
137
+
118
138
  ### `janusMail: locales holds the same locale twice`
119
139
 
120
140
  `['en', 'en']`. List each once.
@@ -169,6 +189,18 @@ out: the defaults could not render them in that locale. Pass all five — see
169
189
  [Adding a locale](guide/locales.md#adding-a-locale) — or drop the locale.
170
190
  The message lists the templates missing.
171
191
 
192
+ ### `janusMail: clock must be a Clock — an object with a now function`
193
+
194
+ `clock` is `@nxgt/janus`'s `Clock`, `{ now(): Date }` — pass the one given to
195
+ `janus({ clock })`, not `Date.now` or `() => new Date()`:
196
+
197
+ ```ts
198
+ import { fixedClock } from '@nxgt/janus';
199
+
200
+ const clock = fixedClock(Date.UTC(2026, 0, 1));
201
+ janusMail({ mailer, from, brand: 'Acme', links, clock });
202
+ ```
203
+
172
204
  ## Sending
173
205
 
174
206
  ### `janusMail.<method>: <field> must be a string`
@@ -186,7 +218,61 @@ if (issued !== null) await mail.resetPassword(issued, { name: issued.user.name }
186
218
 
187
219
  `issued` needs `token` and `email` (`code` and `email` for `signInCode`), and
188
220
  the recipient its `name` — a user whose schema has no name passes one
189
- anyway: `{ name: user.email }`.
221
+ anyway: `{ name: user.email }`. `janusMail.verifyEmail: expiresIn must be a
222
+ string` is the third argument's `expiresIn` given as something else — a
223
+ number of seconds: pass the text to show, `{ expiresIn: '1 hour' }`, or
224
+ leave it out.
225
+
226
+ ### `janusMail.<method>: expiresAt must be a Date`
227
+
228
+ A `TypeError` from `verifyEmail`, `resetPassword` or `signInCode`: the e-mail
229
+ says how long its link or code lasts, from `issued.expiresAt`, and that is
230
+ not a valid `Date`. The usual cause is a flow's answer that went through
231
+ JSON — a job queue, a cache — where a `Date` becomes a string. Nothing is
232
+ sent. Revive it when the job runs, or say the expiry yourself:
233
+
234
+ ```ts
235
+ const issued = { ...job.issued, expiresAt: new Date(job.issued.expiresAt) };
236
+ await mail.verifyEmail(issued, { name: job.name });
237
+ // or
238
+ await mail.verifyEmail(job.issued, { name: job.name }, { expiresIn: '1 day' });
239
+ ```
240
+
241
+ ### `janusMail.<method>: clock.now() must answer a Date`
242
+
243
+ A `TypeError` from `verifyEmail`, `resetPassword` or `signInCode`: the
244
+ `clock` given to `janusMail()` has a `now` that answered something else — a
245
+ number from `Date.now()`, a string, an invalid `Date`. Nothing is sent.
246
+ `clock` is `@nxgt/janus`'s `Clock`: pass the one `janus({ clock })` was
247
+ given, or `{ now: () => new Date() }`.
248
+
249
+ ### `janusMail.<method>: expiresAt is past — the link or code would not work`
250
+
251
+ A `TypeError`: the send came after the link or the code expired — a queue
252
+ that waited too long, an `expiresAt` from another record, or, in tests,
253
+ `janus({ clock: fixedClock(...) })` without the same `clock` given to
254
+ `janusMail()`, so a date in the clock's past is measured against today. Nothing is
255
+ sent: an e-mail whose link fails helps nobody. Issue a new one
256
+ (`auth.verifyEmail.send(user)`, `auth.signInCode.request(email)`) and send
257
+ that; if a queue can hold a send that long, lengthen the `tokens` TTL in
258
+ `janus({ tokens })`.
259
+
260
+ ### `This link expires in 1 hour.` — in the wrong language, or shorter than the `tokens` TTL
261
+
262
+ `expiresIn` is formatted by the runtime's `Intl.NumberFormat`, in the locale
263
+ picked for the recipient. A locale the runtime has no data for — a Node
264
+ built with `small-icu`, a locale of your own — is formatted in the
265
+ runtime's default language. The time left is measured against
266
+ `janusMail({ clock })`, rounded to the minute, then **down** to the largest
267
+ whole unit: 90 minutes is "1 hour", 36 hours "1 day".
268
+ For another wording, pass it:
269
+
270
+ ```ts
271
+ await mail.resetPassword(issued, { name, locale: 'de' }, { expiresIn: '90 Minuten' });
272
+ ```
273
+
274
+ French puts a no-break space between the number and some units (`1 heure`,
275
+ per CLDR): compare with `\s` in a test, not a plain space.
190
276
 
191
277
  ### `MAIL_REFUSED` — `render: <email>: link must be an http:, https: or mailto: URL`
192
278
 
@@ -300,7 +386,15 @@ return new Response(null, { status: 202 });
300
386
 
301
387
  The same holds for `signInCode.request`, whose answer passed unchecked reads
302
388
  `Argument of type 'IssuedCode<…> | null' is not assignable to parameter of
303
- type 'Pick<IssuedCode<unknown>, "email" | "code">'`.
389
+ type 'Pick<IssuedCode<unknown>, "email" | "code" | "expiresAt">'`.
390
+
391
+ ### `TS2345: … Types of property 'expiresAt' are incompatible. Type 'string' is not assignable to type 'Date'.`
392
+
393
+ A flow's answer that went through JSON, typed as it came out. Revive the
394
+ date — `{ ...issued, expiresAt: new Date(issued.expiresAt) }` — or pass the
395
+ expiry as text in the third argument. A sign-in code built by hand without
396
+ `expiresAt` reads `Property 'expiresAt' is missing`: pass the flow's answer
397
+ whole.
304
398
 
305
399
  ### `TS2739: Type '{ … }' is missing the following properties from type 'JanusMailTemplates<…>'`
306
400
 
@@ -28,8 +28,8 @@ font-weight:600;color:#020918;color:lab(2.35721% .367686 -8.51797);">Reset your
28
28
  <!--[if mso]><i style="mso-font-width:114%;mso-text-raise:19px;" hidden>&emsp;</i>
29
29
  <![endif]--> <span style="mso-text-raise:10px;">Choose a new password</span>
30
30
  <!--[if mso]><i style="mso-font-width:114%;" hidden>&emsp;&#8203;</i>
31
- <![endif]--></a></div><div role="separator" style="line-height:24px;">&zwj;</div><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">If the button does not work, open this link: <a href="{{ link }}" style="color:#54a2ff;color:lab(65.0361% -1.42065 -56.9802);text-decoration:underline;">{{ link }}</a></p><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">If you did not ask for this, you can
32
- ignore this e-mail.</p>
31
+ <![endif]--></a></div><div role="separator" style="line-height:24px;">&zwj;</div><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#020918;color:lab(2.35721% .367686 -8.51797);">This link expires in {{ expiresIn }}.</p><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">If the button does not work, open this link: <a href="{{ link }}" style="color:#54a2ff;color:lab(65.0361% -1.42065 -56.9802);text-decoration:underline;">{{
32
+ link }}</a></p><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">If you did not ask for this, you can ignore this e-mail.</p>
33
33
  </td></tr><tr><td style="padding-left:24px;padding-right:24px;padding-top:24px;text-align:center;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);"><p style="margin:0 0 8px;">You received this e-mail because you have an account with {{ brand }}.</p><p style="margin:0;">{{ brand }}</p>
34
34
  </td></tr></table></div>
35
35
  <!--[if mso]>
@@ -12,10 +12,11 @@ Choose a new password
12
12
 
13
13
  {{ link }}
14
14
 
15
+ This link expires in {{ expiresIn }}.
16
+
15
17
  If the button does not work, open this link: {{ link }}
16
18
 
17
- If you did not ask for this, you can
18
- ignore this e-mail.
19
+ If you did not ask for this, you can ignore this e-mail.
19
20
 
20
21
  You received this e-mail because you have an account with {{ brand }}.
21
22
 
@@ -25,7 +25,7 @@
25
25
  </td></tr><tr><td style="border-radius:14px;border-color:#e2e8f0;border-style:solid;border-width:1px;border-color:lab(91.7353% -.998765 -4.76968);background-color:#fff;background-color:lab(100% 0 0);padding:32px;font-size:14px;line-height:20px;color:#020618;color:lab(1.76974% 1.32743 -9.28855);box-shadow:0 0 rgba(0,0,0,0),0 0 rgba(0,0,0,0),0 0 rgba(0,0,0,0),0 0 rgba(0,0,0,0),0 1px 3px rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);"><h1 style="margin:0 0 16px;font-size:24px;line-height:32px;
26
26
  font-weight:600;color:#020918;color:lab(2.35721% .367686 -8.51797);">Your sign-in code</h1><p style="margin:0 0 16px;font-size:14px;line-height:20px;color:#020918;color:lab(2.35721% .367686 -8.51797);">Enter this code to sign in to {{ brand }}. It works once.</p>
27
27
  <table role="presentation" cellpadding="0" cellspacing="0" style="margin-bottom:16px;width:100%;"><tr><td style="border-radius:10px;background-color:#f1f5f9;background-color:lab(96.286% -.852436 -2.46847);padding:16px 24px;text-align:center;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:30px;line-height:36px;font-weight:600;letter-spacing:.3em;color:#020918;color:lab(2.35721% .367686 -8.51797);">{{ code }}
28
- </td></tr></table><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">If you did not try to sign in, you can ignore this e-mail: no one can sign in without the code.</p>
28
+ </td></tr></table><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#020918;color:lab(2.35721% .367686 -8.51797);">This code expires in {{ expiresIn }}.</p><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">If you did not try to sign in, you can ignore this e-mail: no one can sign in without the code.</p>
29
29
  </td></tr><tr><td style="padding-left:24px;padding-right:24px;padding-top:24px;text-align:center;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);"><p style="margin:0 0 8px;">You received this e-mail because you have an account with {{ brand }}.</p><p style="margin:0;">{{ brand }}</p>
30
30
  </td></tr></table></div>
31
31
  <!--[if mso]>
@@ -8,6 +8,8 @@ Enter this code to sign in to {{ brand }}. It works once.
8
8
 
9
9
  {{ code }}
10
10
 
11
+ This code expires in {{ expiresIn }}.
12
+
11
13
  If you did not try to sign in, you can ignore this e-mail: no one can sign in without the code.
12
14
 
13
15
  You received this e-mail because you have an account with {{ brand }}.
@@ -28,8 +28,8 @@ background-color:#27272a;padding:10px 16px;font-family:ui-sans-serif,system-ui,-
28
28
  <!--[if mso]><i style="mso-font-width:114%;mso-text-raise:19px;" hidden>&emsp;</i>
29
29
  <![endif]--> <span style="mso-text-raise:10px;">Confirm my address</span>
30
30
  <!--[if mso]><i style="mso-font-width:114%;" hidden>&emsp;&#8203;</i>
31
- <![endif]--></a></div><div role="separator" style="line-height:24px;">&zwj;</div><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">If the button does not work, open this link: <a href="{{ link }}" style="color:#54a2ff;color:lab(65.0361% -1.42065 -56.9802);text-decoration:underline;">{{ link }}</a></p><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">If you did not ask for this, you can
32
- ignore this e-mail.</p>
31
+ <![endif]--></a></div><div role="separator" style="line-height:24px;">&zwj;</div><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#020918;color:lab(2.35721% .367686 -8.51797);">This link expires in {{ expiresIn }}.</p><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">If the button does not work, open this link: <a href="{{ link }}" style="color:#54a2ff;color:lab(65.0361% -1.42065 -56.9802);text-decoration:underline;">{{
32
+ link }}</a></p><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">If you did not ask for this, you can ignore this e-mail.</p>
33
33
  </td></tr><tr><td style="padding-left:24px;padding-right:24px;padding-top:24px;text-align:center;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);"><p style="margin:0 0 8px;">You received this e-mail because you have an account with {{ brand }}.</p><p style="margin:0;">{{ brand }}</p>
34
34
  </td></tr></table></div>
35
35
  <!--[if mso]>
@@ -12,10 +12,11 @@ Confirm my address
12
12
 
13
13
  {{ link }}
14
14
 
15
+ This link expires in {{ expiresIn }}.
16
+
15
17
  If the button does not work, open this link: {{ link }}
16
18
 
17
- If you did not ask for this, you can
18
- ignore this e-mail.
19
+ If you did not ask for this, you can ignore this e-mail.
19
20
 
20
21
  You received this e-mail because you have an account with {{ brand }}.
21
22
 
@@ -27,8 +27,8 @@ style="display:inline-block;border-radius:9999px;background-color:#27272a;paddin
27
27
  <!--[if mso]><i style="mso-font-width:114%;mso-text-raise:19px;" hidden>&emsp;</i>
28
28
  <![endif]--> <span style="mso-text-raise:10px;">Choisir un nouveau mot de passe</span>
29
29
  <!--[if mso]><i style="mso-font-width:114%;" hidden>&emsp;&#8203;</i>
30
- <![endif]--></a></div><div role="separator" style="line-height:24px;">&zwj;</div><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">Si le bouton ne fonctionne pas, ouvrez ce lien&nbsp;: <a href="{{ link }}" style="color:#54a2ff;color:lab(65.0361% -1.42065 -56.9802);text-decoration:underline;">{{ link }}</a></p><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">Si vous n'êtes pas à l'origine
31
- de cette demande, vous pouvez ignorer cet e-mail.</p>
30
+ <![endif]--></a></div><div role="separator" style="line-height:24px;">&zwj;</div><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#020918;color:lab(2.35721% .367686 -8.51797);">Ce lien expire dans {{ expiresIn }}.</p><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">Si le bouton ne fonctionne pas, ouvrez ce lien&nbsp;: <a href="{{ link }}" style="color:#54a2ff;color:lab(65.0361% -1.42065 -56.9802);text-decoration:
31
+ underline;">{{ link }}</a></p><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail.</p>
32
32
  </td></tr><tr><td style="padding-left:24px;padding-right:24px;padding-top:24px;text-align:center;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);"><p style="margin:0 0 8px;">Vous recevez cet e-mail parce que vous avez un compte chez {{ brand }}.</p><p style="margin:0;">{{ brand }}</p>
33
33
  </td></tr></table></div>
34
34
  <!--[if mso]>
@@ -12,10 +12,11 @@ Choisir un nouveau mot de passe
12
12
 
13
13
  {{ link }}
14
14
 
15
+ Ce lien expire dans {{ expiresIn }}.
16
+
15
17
  Si le bouton ne fonctionne pas, ouvrez ce lien : {{ link }}
16
18
 
17
- Si vous n'êtes pas à l'origine
18
- de cette demande, vous pouvez ignorer cet e-mail.
19
+ Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail.
19
20
 
20
21
  Vous recevez cet e-mail parce que vous avez un compte chez {{ brand }}.
21
22
 
@@ -25,7 +25,7 @@
25
25
  </td></tr><tr><td style="border-radius:14px;border-color:#e2e8f0;border-style:solid;border-width:1px;border-color:lab(91.7353% -.998765 -4.76968);background-color:#fff;background-color:lab(100% 0 0);padding:32px;font-size:14px;line-height:20px;color:#020618;color:lab(1.76974% 1.32743 -9.28855);box-shadow:0 0 rgba(0,0,0,0),0 0 rgba(0,0,0,0),0 0 rgba(0,0,0,0),0 0 rgba(0,0,0,0),0 1px 3px rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);"><h1 style="margin:0 0 16px;font-size:24px;line-height:32px;
26
26
  font-weight:600;color:#020918;color:lab(2.35721% .367686 -8.51797);">Votre code de connexion</h1><p style="margin:0 0 16px;font-size:14px;line-height:20px;color:#020918;color:lab(2.35721% .367686 -8.51797);">Saisissez ce code pour vous connecter à {{ brand }}. Il ne fonctionne qu'une fois.</p>
27
27
  <table role="presentation" cellpadding="0" cellspacing="0" style="margin-bottom:16px;width:100%;"><tr><td style="border-radius:10px;background-color:#f1f5f9;background-color:lab(96.286% -.852436 -2.46847);padding:16px 24px;text-align:center;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:30px;line-height:36px;font-weight:600;letter-spacing:.3em;color:#020918;color:lab(2.35721% .367686 -8.51797);">{{ code }}
28
- </td></tr></table><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">Si vous n'avez pas essayé de vous connecter, vous pouvez ignorer cet e-mail&nbsp;: personne ne peut se connecter sans ce code.</p>
28
+ </td></tr></table><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#020918;color:lab(2.35721% .367686 -8.51797);">Ce code expire dans {{ expiresIn }}.</p><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">Si vous n'avez pas essayé de vous connecter, vous pouvez ignorer cet e-mail&nbsp;: personne ne peut se connecter sans ce code.</p>
29
29
  </td></tr><tr><td style="padding-left:24px;padding-right:24px;padding-top:24px;text-align:center;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);"><p style="margin:0 0 8px;">Vous recevez cet e-mail parce que vous avez un compte chez {{ brand }}.</p><p style="margin:0;">{{ brand }}</p>
30
30
  </td></tr></table></div>
31
31
  <!--[if mso]>
@@ -8,6 +8,8 @@ Saisissez ce code pour vous connecter à {{ brand }}. Il ne fonctionne qu'une fo
8
8
 
9
9
  {{ code }}
10
10
 
11
+ Ce code expire dans {{ expiresIn }}.
12
+
11
13
  Si vous n'avez pas essayé de vous connecter, vous pouvez ignorer cet e-mail : personne ne peut se connecter sans ce code.
12
14
 
13
15
  Vous recevez cet e-mail parce que vous avez un compte chez {{ brand }}.
@@ -28,8 +28,8 @@ border-radius:9999px;background-color:#27272a;padding:10px 16px;font-family:ui-s
28
28
  <!--[if mso]><i style="mso-font-width:114%;mso-text-raise:19px;" hidden>&emsp;</i>
29
29
  <![endif]--> <span style="mso-text-raise:10px;">Confirmer mon adresse</span>
30
30
  <!--[if mso]><i style="mso-font-width:114%;" hidden>&emsp;&#8203;</i>
31
- <![endif]--></a></div><div role="separator" style="line-height:24px;">&zwj;</div><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">Si le bouton ne fonctionne pas, ouvrez ce lien&nbsp;: <a href="{{ link }}" style="color:#54a2ff;color:lab(65.0361% -1.42065 -56.9802);text-decoration:underline;">{{ link }}</a></p><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">Si vous n'êtes pas à l'origine
32
- de cette demande, vous pouvez ignorer cet e-mail.</p>
31
+ <![endif]--></a></div><div role="separator" style="line-height:24px;">&zwj;</div><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#020918;color:lab(2.35721% .367686 -8.51797);">Ce lien expire dans {{ expiresIn }}.</p><p style="margin:0 0 16px;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">Si le bouton ne fonctionne pas, ouvrez ce lien&nbsp;: <a href="{{ link }}" style="color:#54a2ff;color:lab(65.0361% -1.42065 -56.9802);text-decoration:
32
+ underline;">{{ link }}</a></p><p style="margin:0 0 16px;font-size:10px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);">Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail.</p>
33
33
  </td></tr><tr><td style="padding-left:24px;padding-right:24px;padding-top:24px;text-align:center;font-size:12px;line-height:16px;color:#62748e;color:lab(48.0876% -2.03595 -16.5814);"><p style="margin:0 0 8px;">Vous recevez cet e-mail parce que vous avez un compte chez {{ brand }}.</p><p style="margin:0;">{{ brand }}</p>
34
34
  </td></tr></table></div>
35
35
  <!--[if mso]>
@@ -12,10 +12,11 @@ Confirmer mon adresse
12
12
 
13
13
  {{ link }}
14
14
 
15
+ Ce lien expire dans {{ expiresIn }}.
16
+
15
17
  Si le bouton ne fonctionne pas, ouvrez ce lien : {{ link }}
16
18
 
17
- Si vous n'êtes pas à l'origine
18
- de cette demande, vous pouvez ignorer cet e-mail.
19
+ Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail.
19
20
 
20
21
  Vous recevez cet e-mail parce que vous avez un compte chez {{ brand }}.
21
22
 
@@ -58,6 +58,7 @@
58
58
  "reset-password": {
59
59
  "variables": [
60
60
  "brand",
61
+ "expiresIn",
61
62
  "link",
62
63
  "name"
63
64
  ],
@@ -82,7 +83,8 @@
82
83
  "sign-in-code": {
83
84
  "variables": [
84
85
  "brand",
85
- "code"
86
+ "code",
87
+ "expiresIn"
86
88
  ],
87
89
  "urlVariables": [],
88
90
  "subject": {
@@ -103,6 +105,7 @@
103
105
  "verify-email": {
104
106
  "variables": [
105
107
  "brand",
108
+ "expiresIn",
106
109
  "link",
107
110
  "name"
108
111
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus-mail",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "The e-mails of @nxgt/janus's flows — e-mail verification, password reset, sign-in code, password changed, e-mail changed — rendered in English and French from a prebuilt Maizzle build, with your brand, sent through any @nxgt/mail transport",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,11 +54,11 @@
54
54
  "@maizzle/framework": "6.1.7",
55
55
  "@maizzle/tailwindcss": "1.5.6",
56
56
  "@nxgt/janus": "^0.8.8",
57
- "@nxgt/mail": "0.5.1",
58
- "@nxgt/mail-config": "0.2.0",
59
- "@nxgt/mail-i18n": "0.3.0",
60
- "@nxgt/mail-presets": "0.1.2",
61
- "@nxgt/mail-ui": "0.1.1",
57
+ "@nxgt/mail": "0.6.0",
58
+ "@nxgt/mail-config": "0.2.1",
59
+ "@nxgt/mail-i18n": "0.4.0",
60
+ "@nxgt/mail-presets": "0.3.0",
61
+ "@nxgt/mail-ui": "0.3.0",
62
62
  "@types/bun": "^1.4.2",
63
63
  "vue": "3.5.43",
64
64
  "vue-tsc": "^3.3.11"