@nxgt/janus-mail 0.1.0 → 0.2.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 (43) hide show
  1. package/README.md +56 -15
  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 +1 -1
  25. package/docs/guide/locales.md +6 -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 +97 -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
package/README.md CHANGED
@@ -29,8 +29,8 @@ The e-mails are built with [Maizzle](https://maizzle.com) when **this
29
29
  package** is built — from
30
30
  [`@nxgt/mail-presets`](https://www.npmjs.com/package/@nxgt/mail-presets),
31
31
  CSS inlined for mail clients — and shipped as HTML and text. At send time
32
- they are only filled in: your brand, the recipient's name, your links, every
33
- value escaped. No template engine and no Maizzle run in your server.
32
+ they are only filled in: your brand, the recipient's name, your links, how
33
+ long the link or code lasts, every value escaped. No template engine and no Maizzle run in your server.
34
34
 
35
35
  > **0.x.** A minor version may still change the surface; the changelog says how.
36
36
 
@@ -54,11 +54,12 @@ runtime. Like `@nxgt/janus`, it expects `"moduleResolution": "bundler"`.
54
54
 
55
55
  | Export | What it is |
56
56
  | --- | --- |
57
- | `janusMail(options)` | The e-mails, over your mailer: `verifyEmail`, `resetPassword`, `signInCode`, `passwordChanged`, `emailChanged`, and `templates` and `locales`. Options: `mailer`, `from`, `replyTo?`, `brand`, `links` (`verifyEmail`, `resetPassword`, `secureAccount`), `locales?`, `fallbackLocale?`, `templates?`. A wrong option is a bare `TypeError`, thrown here |
57
+ | `janusMail(options)` | The e-mails, over your mailer: `verifyEmail`, `resetPassword`, `signInCode`, `passwordChanged`, `emailChanged`, and `templates` and `locales`. Options: `mailer`, `from`, `replyTo?`, `brand`, `links` (`verifyEmail`, `resetPassword`, `secureAccount`), `locales?`, `fallbackLocale?`, `templates?`, `clock?`. A wrong option is a bare `TypeError`, thrown here |
58
58
  | `janusTemplates()` | The five default templates alone, each `(variables & { locale }) => Rendered` |
59
59
  | `JanusMail<L>`, `JanusMailOptions<L>` | What `janusMail()` answers and takes, for the locales `L` |
60
60
  | `JanusMailTemplate<V, L>`, `JanusMailTemplates<L>`, `JanusMailTemplateName` | One template, the five of them, and their names |
61
- | `JanusMailVariables` | What each template is given: `brand`, and `name`, `link`, `code` or `newEmail` as its e-mail needs |
61
+ | `JanusMailVariables` | What each template is given: `brand`, and `name`, `link`, `code`, `expiresIn` or `newEmail` as its e-mail needs |
62
+ | `JanusMailSendOptions` | The third argument of `verifyEmail`, `resetPassword` and `signInCode`: `{ expiresIn? }`, the expiry as text, over the one derived |
62
63
  | `JanusMailLocale` | `'en' \| 'fr'`: the locales the defaults are built in |
63
64
  | `JanusMailLinks`, `Recipient` | The `links` option; who an e-mail is for — `{ name, locale? }` |
64
65
 
@@ -68,12 +69,21 @@ class.
68
69
 
69
70
  | Method | Sends | To |
70
71
  | --- | --- | --- |
71
- | `verifyEmail(issued, to)` | `issued` from `auth.verifyEmail.send(user)`; a link from `links.verifyEmail(issued.token)` | `issued.email` |
72
- | `resetPassword(issued, to)` | `issued` from `auth.resetPassword.request(email)`, checked for `null`; a link from `links.resetPassword(issued.token)` | `issued.email` |
73
- | `signInCode(issued, to?)` | `issued.code` from `auth.signInCode.request(email)` — **never the challenge** | `issued.email` |
72
+ | `verifyEmail(issued, to, options?)` | `issued` from `auth.verifyEmail.send(user)`; a link from `links.verifyEmail(issued.token)`, and how long it lasts | `issued.email` |
73
+ | `resetPassword(issued, to, options?)` | `issued` from `auth.resetPassword.request(email)`, checked for `null`; a link from `links.resetPassword(issued.token)`, and how long it lasts | `issued.email` |
74
+ | `signInCode(issued, to?, options?)` | `issued.code` from `auth.signInCode.request(email)`, and how long it lasts — **never the challenge** | `issued.email` |
74
75
  | `passwordChanged(to)` | A notice, with `links.secureAccount()` | `to.email` |
75
76
  | `emailChanged(to)` | A notice naming `to.newEmail`, with `links.secureAccount()` | `to.formerEmail` |
76
77
 
78
+ The three e-mails of a link or a code say how long it lasts — "This link
79
+ expires in 1 hour.", "Ce lien expire dans 1 heure." — from the flow's
80
+ `issued.expiresAt`: the time left at send time, rounded to the minute, then
81
+ down to the largest whole unit (days, hours or minutes), formatted with
82
+ `Intl.NumberFormat` in the recipient's locale. The time is read from
83
+ `janusMail({ clock })` — give it the clock you gave `janus({ clock })` —
84
+ else the system clock. Pass `{ expiresIn }` to say it yourself, as plain
85
+ text in the recipient's language.
86
+
77
87
  ## Usage
78
88
 
79
89
  The examples use `mail` from above, and an `auth` whose user schema has a
@@ -110,6 +120,12 @@ if (issued !== null) {
110
120
  }
111
121
  ```
112
122
 
123
+ ### Saying the expiry yourself
124
+
125
+ ```ts
126
+ await mail.verifyEmail(issued, { name: user.name, locale: 'fr' }, { expiresIn: '24 heures' });
127
+ ```
128
+
113
129
  ### Telling a user their password changed
114
130
 
115
131
  ```ts
@@ -192,9 +208,27 @@ templates is a compile error, and a `TypeError` in JavaScript.
192
208
  body and the footer, escaped: no logo, no link, no markup. For those, replace
193
209
  the templates.
194
210
 
195
- **The default e-mails do not state the expiry.** `@nxgt/mail-presets`' bodies
196
- say nothing of how long a link or a code lasts. If yours must, replace the
197
- template: `issued.expiresAt` is yours to format.
211
+ **Send the flow's answer as it came: `expiresAt` must be a `Date`.** The
212
+ expiry is derived from `issued.expiresAt` at send time. A flow's answer that
213
+ went through JSON — a job queue — holds a string there, which is a compile
214
+ error and, in JavaScript, a `TypeError` (`expiresAt must be a Date`); revive
215
+ it with `new Date(...)`, or pass `{ expiresIn }`. An `expiresAt` already past
216
+ is a `TypeError` too: the link would not work, so nothing is sent.
217
+
218
+ **Give `janusMail()` the clock you gave `janus()`.** The time left is
219
+ measured against `clock`, the system clock by default. Tests that run
220
+ `janus({ clock: fixedClock(...) })` issue an `expiresAt` in the clock's time:
221
+ without the same `clock` here, a clock set in the past makes every send an
222
+ `expiresAt is past` `TypeError`, and one set ahead a wrong duration.
223
+
224
+ **Locales are BCP 47 tags: `fr-CA`, never `fr_CA`.** The expiry is
225
+ formatted by `Intl` in the locale picked, so a locale `Intl` refuses is a
226
+ `TypeError` from `janusMail()`.
227
+
228
+ **The expiry is formatted by the runtime's `Intl`.** A locale the runtime has
229
+ no data for is formatted in its default language, and French puts a no-break
230
+ space between the number and some units, as CLDR says. For a wording of your
231
+ own, pass `{ expiresIn }`.
198
232
 
199
233
  **A failed send is `MAIL_FAILED`; answer it as an outage.** In an HTTP
200
234
  handler, a `MailFailure` is a `503` — nothing is known to have been sent —
@@ -224,10 +258,12 @@ The symptoms and fixes are in [troubleshooting](docs/troubleshooting.md).
224
258
 
225
259
  ## Type safety, counted
226
260
 
227
- **Twenty plausible mistakes, twenty refused at compile time.**
261
+ **Twenty-five plausible mistakes, twenty-five refused at compile time.**
228
262
  [`test/types/refusals.ts`](test/types/refusals.ts) holds one
229
263
  `@ts-expect-error` per mistake, beside the calls that must keep compiling —
230
- among them adding a language with every template:
264
+ among them adding a language with every template — and
265
+ [`test/types/expiry-refusals.ts`](test/types/expiry-refusals.ts) the five of
266
+ the expiry and the clock, 21 to 25:
231
267
 
232
268
  1. A sign-in code given to `verifyEmail`: it has no token.
233
269
  2. A one-time token given to `signInCode`: it has no code.
@@ -249,9 +285,14 @@ among them adding a language with every template:
249
285
  18. A template read from `mail.templates` that does not exist.
250
286
  19. A link computed asynchronously.
251
287
  20. A link answered as a `URL` object rather than its `href`.
252
-
253
- In JavaScript, 19 and 20 are a `TypeError` at send time instead, naming the
254
- call.
288
+ 21. A sign-in code without its `expiresAt`.
289
+ 22. A flow's answer that went through JSON: `expiresAt` a string.
290
+ 23. `expiresIn` given as a number of seconds rather than the text to show.
291
+ 24. `expiresIn` given with the recipient rather than as the send's option.
292
+ 25. `clock` given as a function rather than `@nxgt/janus`'s `Clock`.
293
+
294
+ In JavaScript, 19 to 23 are a `TypeError` at send time instead, naming the
295
+ call or the field, and 25 one from `janusMail()`.
255
296
 
256
297
  [`test/types/variables.ts`](test/types/variables.ts) also holds
257
298
  `JanusMailVariables` equal to the variables of the build: an e-mail that
@@ -0,0 +1,7 @@
1
+ import type { ResolvedOptions } from './options';
2
+ import type { JanusMail } from './types';
3
+ type ExpiringMails = Pick<JanusMail<string>, 'verifyEmail' | 'resetPassword' | 'signInCode'>;
4
+ /** `verifyEmail`, `resetPassword` and `signInCode`, over the resolved options. */
5
+ export declare function expiringMails(options: ResolvedOptions): ExpiringMails;
6
+ export {};
7
+ //# sourceMappingURL=expiring-mails.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"expiring-mails.d.ts","sourceRoot":"","sources":["../src/expiring-mails.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAEjD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC,KAAK,aAAa,GAAG,IAAI,CACxB,SAAS,CAAC,MAAM,CAAC,EACjB,aAAa,GAAG,eAAe,GAAG,YAAY,CAC9C,CAAC;AAoBF,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,OAAO,EAAE,eAAe,GAAG,aAAa,CAqBrE"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `expiresIn`: how long the link or the code of an e-mail stays valid, as
3
+ * the text the e-mail shows — "1 hour", "1 heure". Derived from the
4
+ * `expiresAt` the flow answered and the locale the recipient gets, unless the
5
+ * send passes its own.
6
+ */
7
+ import type { Clock } from '@nxgt/janus';
8
+ /**
9
+ * A duration in the locale, in the largest unit it holds at least once:
10
+ * rounded to the minute first — the flow set `expiresAt` a moment before the
11
+ * send, so an hour is 59 minutes and 59 seconds by then — and then down to a
12
+ * whole unit: 90 minutes is "1 hour", 36 hours "1 day". At 30 seconds or
13
+ * more, the e-mail never promises more than half a minute beyond what is
14
+ * left; under that, it says "1 minute".
15
+ *
16
+ * ```ts
17
+ * formatExpiry(3_600_000, 'fr'); // '1 heure'
18
+ * ```
19
+ */
20
+ export declare function formatExpiry(ms: number, locale: string): string;
21
+ /**
22
+ * The `expiresIn` of one send: the one passed, as is, or the time left
23
+ * until the flow's `expiresAt` by `clock` — the one given to `janus()`, when
24
+ * it was given to `janusMail()` too — in `locale`. A `TypeError` naming the method
25
+ * when `expiresAt` is not a valid `Date` — a flow's answer that went through
26
+ * JSON holds a string there — or is already past.
27
+ */
28
+ export declare function expiresInFor(method: string, issued: unknown, locale: string, options: unknown, clock: Clock): string;
29
+ //# sourceMappingURL=expiry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"expiry.d.ts","sourceRoot":"","sources":["../src/expiry.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAMzC;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAa/D;AAcD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC3B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,KAAK,GACV,MAAM,CAqBR"}
@@ -13,15 +13,18 @@ export interface MailEmails {
13
13
  };
14
14
  "reset-password": {
15
15
  readonly brand: string | number;
16
+ readonly expiresIn: string | number;
16
17
  readonly link: string;
17
18
  readonly name: string | number;
18
19
  };
19
20
  "sign-in-code": {
20
21
  readonly brand: string | number;
21
22
  readonly code: string | number;
23
+ readonly expiresIn: string | number;
22
24
  };
23
25
  "verify-email": {
24
26
  readonly brand: string | number;
27
+ readonly expiresIn: string | number;
25
28
  readonly link: string;
26
29
  readonly name: string | number;
27
30
  };
@@ -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,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAC7G,cAAc,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACpF,cAAc,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;CAC3G"}
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,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;CAChJ"}
package/dist/index.d.ts CHANGED
@@ -16,5 +16,5 @@
16
16
  */
17
17
  export { janusMail } from './janus-mail';
18
18
  export { janusTemplates } from './templates';
19
- export type { JanusMail, JanusMailLinks, JanusMailLocale, JanusMailOptions, JanusMailTemplate, JanusMailTemplateName, JanusMailTemplates, JanusMailVariables, Recipient, } from './types';
19
+ export type { JanusMail, JanusMailLinks, JanusMailLocale, JanusMailOptions, JanusMailSendOptions, JanusMailTemplate, JanusMailTemplateName, JanusMailTemplates, JanusMailVariables, Recipient, } from './types';
20
20
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,YAAY,EACX,SAAS,EACT,cAAc,EACd,eAAe,EACf,gBAAgB,EAChB,iBAAiB,EACjB,qBAAqB,EACrB,kBAAkB,EAClB,kBAAkB,EAClB,SAAS,GACT,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,YAAY,EACX,SAAS,EACT,cAAc,EACd,eAAe,EACf,gBAAgB,EAChB,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,kBAAkB,EAClB,kBAAkB,EAClB,SAAS,GACT,MAAM,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,128 @@
1
- // src/janus-mail.ts
1
+ // src/expiry.ts
2
+ var MINUTE_MS = 60000;
3
+ var MINUTES_IN_HOUR = 60;
4
+ var MINUTES_IN_DAY = 24 * MINUTES_IN_HOUR;
5
+ function formatExpiry(ms, locale) {
6
+ const minutes = Math.max(1, Math.round(ms / MINUTE_MS));
7
+ const [value, unit] = minutes >= MINUTES_IN_DAY ? [Math.floor(minutes / MINUTES_IN_DAY), "day"] : minutes >= MINUTES_IN_HOUR ? [Math.floor(minutes / MINUTES_IN_HOUR), "hour"] : [minutes, "minute"];
8
+ return new Intl.NumberFormat(locale, {
9
+ style: "unit",
10
+ unit,
11
+ unitDisplay: "long"
12
+ }).format(value);
13
+ }
14
+ function passedExpiresIn(method, options) {
15
+ const read = typeof options === "object" && options !== null ? options.expiresIn : undefined;
16
+ if (read !== undefined && typeof read !== "string") {
17
+ throw new TypeError(`janusMail.${method}: expiresIn must be a string`);
18
+ }
19
+ return read;
20
+ }
21
+ function expiresInFor(method, issued, locale, options, clock) {
22
+ const passed = passedExpiresIn(method, options);
23
+ if (passed !== undefined)
24
+ return passed;
25
+ const expiresAt = typeof issued === "object" && issued !== null ? issued.expiresAt : undefined;
26
+ if (!(expiresAt instanceof Date) || Number.isNaN(expiresAt.getTime())) {
27
+ throw new TypeError(`janusMail.${method}: expiresAt must be a Date`);
28
+ }
29
+ const now = clock.now();
30
+ if (!(now instanceof Date) || Number.isNaN(now.getTime())) {
31
+ throw new TypeError(`janusMail.${method}: clock.now() must answer a Date`);
32
+ }
33
+ const left = expiresAt.getTime() - now.getTime();
34
+ if (left <= 0) {
35
+ throw new TypeError(`janusMail.${method}: expiresAt is past — the link or code would not work`);
36
+ }
37
+ return formatExpiry(left, locale);
38
+ }
39
+
40
+ // src/send.ts
2
41
  import { pickLocale } from "@nxgt/mail";
42
+ function field(method, value, key) {
43
+ const read = typeof value === "object" && value !== null ? value[key] : undefined;
44
+ if (typeof read !== "string") {
45
+ throw new TypeError(`janusMail.${method}: ${key} must be a string`);
46
+ }
47
+ return read;
48
+ }
49
+ function link(method, value, call) {
50
+ if (typeof value !== "string") {
51
+ throw new TypeError(`janusMail.${method}: links.${call} must answer a string`);
52
+ }
53
+ return value;
54
+ }
55
+ function localeFor(options, to) {
56
+ const wanted = typeof to === "object" && to !== null ? to.locale : undefined;
57
+ return pickLocale(wanted, options.locales, options.fallbackLocale);
58
+ }
59
+ async function send(options, name, address, variables) {
60
+ const template = options.templates[name];
61
+ const { subject, html, text } = await template(variables);
62
+ const replyTo = options.replyTo === undefined ? {} : { replyTo: options.replyTo };
63
+ return options.mailer.send({
64
+ to: address,
65
+ from: options.from,
66
+ ...replyTo,
67
+ subject,
68
+ html,
69
+ text
70
+ });
71
+ }
72
+
73
+ // src/expiring-mails.ts
74
+ function linkMail(options, name) {
75
+ return async (issued, to, sendOptions) => {
76
+ const token = field(name, issued, "token");
77
+ const locale = localeFor(options, to);
78
+ return send(options, name, field(name, issued, "email"), {
79
+ brand: options.brand,
80
+ name: field(name, to, "name"),
81
+ link: link(name, options.links[name](token), `${name}(token)`),
82
+ expiresIn: expiresInFor(name, issued, locale, sendOptions, options.clock),
83
+ locale
84
+ });
85
+ };
86
+ }
87
+ function expiringMails(options) {
88
+ return {
89
+ verifyEmail: linkMail(options, "verifyEmail"),
90
+ resetPassword: linkMail(options, "resetPassword"),
91
+ async signInCode(issued, to, sendOptions) {
92
+ const locale = localeFor(options, to);
93
+ return send(options, "signInCode", field("signInCode", issued, "email"), {
94
+ brand: options.brand,
95
+ code: field("signInCode", issued, "code"),
96
+ expiresIn: expiresInFor("signInCode", issued, locale, sendOptions, options.clock),
97
+ locale
98
+ });
99
+ }
100
+ };
101
+ }
102
+
103
+ // src/notice-mails.ts
104
+ function noticeMails(options) {
105
+ const { brand, links } = options;
106
+ return {
107
+ async passwordChanged(to) {
108
+ return send(options, "passwordChanged", field("passwordChanged", to, "email"), {
109
+ brand,
110
+ name: field("passwordChanged", to, "name"),
111
+ link: link("passwordChanged", links.secureAccount(), "secureAccount()"),
112
+ locale: localeFor(options, to)
113
+ });
114
+ },
115
+ async emailChanged(to) {
116
+ return send(options, "emailChanged", field("emailChanged", to, "formerEmail"), {
117
+ brand,
118
+ name: field("emailChanged", to, "name"),
119
+ link: link("emailChanged", links.secureAccount(), "secureAccount()"),
120
+ newEmail: field("emailChanged", to, "newEmail"),
121
+ locale: localeFor(options, to)
122
+ });
123
+ }
124
+ };
125
+ }
3
126
 
4
127
  // src/generated/locales.ts
5
128
  var LOCALES = ["en", "fr"];
@@ -17,9 +140,9 @@ function defaultRenderer() {
17
140
  // src/templates.ts
18
141
  function janusTemplates() {
19
142
  return Object.freeze({
20
- verifyEmail: ({ brand, name, link, locale }) => defaultRenderer().render("verify-email", { brand, name, link }, { locale }),
21
- resetPassword: ({ brand, name, link, locale }) => defaultRenderer().render("reset-password", { brand, name, link }, { locale }),
22
- signInCode: ({ brand, code, locale }) => defaultRenderer().render("sign-in-code", { brand, code }, { locale }),
143
+ verifyEmail: ({ brand, name, link, expiresIn, locale }) => defaultRenderer().render("verify-email", { brand, name, link, expiresIn }, { locale }),
144
+ resetPassword: ({ brand, name, link, expiresIn, locale }) => defaultRenderer().render("reset-password", { brand, name, link, expiresIn }, { locale }),
145
+ signInCode: ({ brand, code, expiresIn, locale }) => defaultRenderer().render("sign-in-code", { brand, code, expiresIn }, { locale }),
23
146
  passwordChanged: ({ brand, name, link, locale }) => defaultRenderer().render("password-changed", { brand, name, link }, { locale }),
24
147
  emailChanged: ({ brand, name, link, newEmail, locale }) => defaultRenderer().render("email-changed", { brand, name, link, newEmail }, { locale })
25
148
  });
@@ -34,6 +157,7 @@ var TEMPLATE_NAMES = [
34
157
  "emailChanged"
35
158
  ];
36
159
  var LINK_NAMES = ["verifyEmail", "resetPassword", "secureAccount"];
160
+ var SYSTEM_CLOCK = Object.freeze({ now: () => new Date });
37
161
  function isObject(value) {
38
162
  return typeof value === "object" && value !== null && !Array.isArray(value);
39
163
  }
@@ -68,12 +192,31 @@ function checkSending(options) {
68
192
  }
69
193
  }
70
194
  }
195
+ function resolveClock(clock) {
196
+ if (clock === undefined)
197
+ return SYSTEM_CLOCK;
198
+ if (!isObject(clock) || typeof clock.now !== "function") {
199
+ refuse("clock must be a Clock — an object with a now function");
200
+ }
201
+ return Object.freeze({ now: clock.now.bind(clock) });
202
+ }
203
+ function isLanguageTag(locale) {
204
+ try {
205
+ Intl.getCanonicalLocales(locale);
206
+ return true;
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
71
211
  function resolveLocales(options) {
72
212
  const wanted = options.locales ?? LOCALES;
73
213
  if (!Array.isArray(wanted) || wanted.length === 0 || !wanted.every((locale) => typeof locale === "string" && locale !== "")) {
74
214
  refuse("locales must list at least one locale, as ['en', 'fr']");
75
215
  }
76
216
  const locales = Object.freeze([...wanted]);
217
+ if (!locales.every(isLanguageTag)) {
218
+ refuse("locales must be BCP 47 language tags, with hyphens, as 'fr-CA'");
219
+ }
77
220
  if (new Set(locales).size !== locales.length) {
78
221
  refuse("locales holds the same locale twice");
79
222
  }
@@ -134,92 +277,17 @@ function resolveOptions(options) {
134
277
  links: frozenLinks(options.links),
135
278
  locales,
136
279
  fallbackLocale,
137
- templates: resolveTemplates(options, locales)
280
+ templates: resolveTemplates(options, locales),
281
+ clock: resolveClock(options.clock)
138
282
  });
139
283
  }
140
284
 
141
285
  // src/janus-mail.ts
142
- function field(method, value, key) {
143
- const read = typeof value === "object" && value !== null ? value[key] : undefined;
144
- if (typeof read !== "string") {
145
- throw new TypeError(`janusMail.${method}: ${key} must be a string`);
146
- }
147
- return read;
148
- }
149
- function link(method, value, call) {
150
- if (typeof value !== "string") {
151
- throw new TypeError(`janusMail.${method}: links.${call} must answer a string`);
152
- }
153
- return value;
154
- }
155
- function localeFor(options, to) {
156
- const wanted = typeof to === "object" && to !== null ? to.locale : undefined;
157
- return pickLocale(wanted, options.locales, options.fallbackLocale);
158
- }
159
- async function send(options, name, address, variables) {
160
- const template = options.templates[name];
161
- const { subject, html, text } = await template(variables);
162
- const replyTo = options.replyTo === undefined ? {} : { replyTo: options.replyTo };
163
- return options.mailer.send({
164
- to: address,
165
- from: options.from,
166
- ...replyTo,
167
- subject,
168
- html,
169
- text
170
- });
171
- }
172
- function methods(options) {
173
- const { brand, links } = options;
174
- return {
175
- async verifyEmail(issued, to) {
176
- const token = field("verifyEmail", issued, "token");
177
- return send(options, "verifyEmail", field("verifyEmail", issued, "email"), {
178
- brand,
179
- name: field("verifyEmail", to, "name"),
180
- link: link("verifyEmail", links.verifyEmail(token), "verifyEmail(token)"),
181
- locale: localeFor(options, to)
182
- });
183
- },
184
- async resetPassword(issued, to) {
185
- const token = field("resetPassword", issued, "token");
186
- return send(options, "resetPassword", field("resetPassword", issued, "email"), {
187
- brand,
188
- name: field("resetPassword", to, "name"),
189
- link: link("resetPassword", links.resetPassword(token), "resetPassword(token)"),
190
- locale: localeFor(options, to)
191
- });
192
- },
193
- async signInCode(issued, to) {
194
- return send(options, "signInCode", field("signInCode", issued, "email"), {
195
- brand,
196
- code: field("signInCode", issued, "code"),
197
- locale: localeFor(options, to)
198
- });
199
- },
200
- async passwordChanged(to) {
201
- return send(options, "passwordChanged", field("passwordChanged", to, "email"), {
202
- brand,
203
- name: field("passwordChanged", to, "name"),
204
- link: link("passwordChanged", links.secureAccount(), "secureAccount()"),
205
- locale: localeFor(options, to)
206
- });
207
- },
208
- async emailChanged(to) {
209
- return send(options, "emailChanged", field("emailChanged", to, "formerEmail"), {
210
- brand,
211
- name: field("emailChanged", to, "name"),
212
- link: link("emailChanged", links.secureAccount(), "secureAccount()"),
213
- newEmail: field("emailChanged", to, "newEmail"),
214
- locale: localeFor(options, to)
215
- });
216
- }
217
- };
218
- }
219
286
  function janusMail(options) {
220
287
  const resolved = resolveOptions(options);
221
288
  const mail = Object.freeze({
222
- ...methods(resolved),
289
+ ...expiringMails(resolved),
290
+ ...noticeMails(resolved),
223
291
  templates: resolved.templates,
224
292
  locales: resolved.locales
225
293
  });
@@ -230,5 +298,5 @@ export {
230
298
  janusTemplates
231
299
  };
232
300
 
233
- //# debugId=B2A314C222070DD464756E2164756E21
301
+ //# debugId=941D325BF4DF9C0164756E2164756E21
234
302
  //# sourceMappingURL=index.js.map