@nxgt/mail 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -0
  3. package/dist/chunks/index-0f7kdb8k.js +114 -0
  4. package/dist/chunks/index-0f7kdb8k.js.map +11 -0
  5. package/dist/chunks/index-vq4e9n8f.js +39 -0
  6. package/dist/chunks/index-vq4e9n8f.js.map +10 -0
  7. package/dist/chunks/index-we4n5yfz.js +28 -0
  8. package/dist/chunks/index-we4n5yfz.js.map +10 -0
  9. package/dist/conformance/assert.d.ts +12 -0
  10. package/dist/conformance/assert.d.ts.map +1 -0
  11. package/dist/conformance/cases/failure.d.ts +4 -0
  12. package/dist/conformance/cases/failure.d.ts.map +1 -0
  13. package/dist/conformance/cases/index.d.ts +6 -0
  14. package/dist/conformance/cases/index.d.ts.map +1 -0
  15. package/dist/conformance/cases/send.d.ts +4 -0
  16. package/dist/conformance/cases/send.d.ts.map +1 -0
  17. package/dist/conformance/describe.d.ts +37 -0
  18. package/dist/conformance/describe.d.ts.map +1 -0
  19. package/dist/conformance/index.d.ts +20 -0
  20. package/dist/conformance/index.d.ts.map +1 -0
  21. package/dist/conformance/index.js +297 -0
  22. package/dist/conformance/index.js.map +16 -0
  23. package/dist/conformance/reference.d.ts +7 -0
  24. package/dist/conformance/reference.d.ts.map +1 -0
  25. package/dist/conformance/sample.d.ts +4 -0
  26. package/dist/conformance/sample.d.ts.map +1 -0
  27. package/dist/conformance/types.d.ts +74 -0
  28. package/dist/conformance/types.d.ts.map +1 -0
  29. package/dist/errors.d.ts +68 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/index.d.ts +21 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +29 -0
  34. package/dist/index.js.map +9 -0
  35. package/dist/locale.d.ts +33 -0
  36. package/dist/locale.d.ts.map +1 -0
  37. package/dist/memory.d.ts +34 -0
  38. package/dist/memory.d.ts.map +1 -0
  39. package/dist/message.d.ts +23 -0
  40. package/dist/message.d.ts.map +1 -0
  41. package/dist/renderer.d.ts +80 -0
  42. package/dist/renderer.d.ts.map +1 -0
  43. package/dist/renderer.js +169 -0
  44. package/dist/renderer.js.map +10 -0
  45. package/dist/types.d.ts +69 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/docs/README.md +16 -0
  48. package/docs/guide/locales.md +141 -0
  49. package/docs/guide/rendering.md +523 -0
  50. package/docs/guide/sending.md +325 -0
  51. package/docs/guide/testing.md +175 -0
  52. package/docs/guide/transports.md +451 -0
  53. package/docs/roadmap.md +112 -0
  54. package/docs/troubleshooting.md +1330 -0
  55. package/package.json +68 -0
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ import {
2
+ pickLocale2,
3
+ parseAcceptLanguage2
4
+ } from "./chunks/index-vq4e9n8f.js";
5
+ import {
6
+ recipientsOf2,
7
+ addressOf2,
8
+ checkMessage2,
9
+ createMemoryMailer2
10
+ } from "./chunks/index-0f7kdb8k.js";
11
+ import {
12
+ MailError2,
13
+ MailFailure2,
14
+ MailRefused2
15
+ } from "./chunks/index-we4n5yfz.js";
16
+ export {
17
+ MailError2 as MailError,
18
+ MailFailure2 as MailFailure,
19
+ MailRefused2 as MailRefused,
20
+ addressOf2 as addressOf,
21
+ checkMessage2 as checkMessage,
22
+ createMemoryMailer2 as createMemoryMailer,
23
+ parseAcceptLanguage2 as parseAcceptLanguage,
24
+ pickLocale2 as pickLocale,
25
+ recipientsOf2 as recipientsOf
26
+ };
27
+
28
+ //# debugId=F6BB19285305093D64756E2164756E21
29
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,9 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [
5
+ ],
6
+ "mappings": "",
7
+ "debugId": "F6BB19285305093D64756E2164756E21",
8
+ "names": []
9
+ }
@@ -0,0 +1,33 @@
1
+ /** What {@link pickLocale} accepts as the wanted locales. */
2
+ export type WantedLocales = string | null | undefined | readonly (string | null | undefined)[];
3
+ /**
4
+ * The locale to render an e-mail in: the first wanted locale this build
5
+ * supports, or `fallback`.
6
+ *
7
+ * `wanted` is in order of preference — typically the recipient's stored
8
+ * locale, then their `Accept-Language` (see {@link parseAcceptLanguage}).
9
+ * For each wanted locale in turn, an exact match wins (case and `_` or `-`
10
+ * do not matter), then a match on the language alone: `fr-CA` picks `fr`,
11
+ * and `fr` picks `fr-CA` when that is the only French supported. Nothing
12
+ * matching, or nothing wanted, answers `fallback`.
13
+ *
14
+ * Pure, with no request context: **the locale of an e-mail is the
15
+ * recipient's**, usually a field of the user, and not the language of the
16
+ * request that triggered the send.
17
+ *
18
+ * Throws a `TypeError` when `supported` is empty or does not hold `fallback`.
19
+ * That is a wiring mistake, not a request's.
20
+ */
21
+ export declare function pickLocale<const L extends string>(wanted: WantedLocales, supported: readonly L[], fallback: NoInfer<L>): L;
22
+ /**
23
+ * The locales of an `Accept-Language` header, most wanted first.
24
+ *
25
+ * Entries are ordered by their `q` weight, ties keeping the header's order;
26
+ * `q=0` entries and `*` are dropped. A missing or empty header answers `[]`.
27
+ *
28
+ * ```ts
29
+ * parseAcceptLanguage('fr-CA,fr;q=0.9,en;q=0.8'); // ['fr-CA', 'fr', 'en']
30
+ * ```
31
+ */
32
+ export declare function parseAcceptLanguage(header: string | null | undefined): string[];
33
+ //# sourceMappingURL=locale.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale.d.ts","sourceRoot":"","sources":["../src/locale.ts"],"names":[],"mappings":"AAAA,6DAA6D;AAC7D,MAAM,MAAM,aAAa,GACtB,MAAM,GACN,IAAI,GACJ,SAAS,GACT,SAAS,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,CAAC;AAM1C;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,UAAU,CAAC,KAAK,CAAC,CAAC,SAAS,MAAM,EAChD,MAAM,EAAE,aAAa,EACrB,SAAS,EAAE,SAAS,CAAC,EAAE,EACvB,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,GAClB,CAAC,CAoBH;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAClC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC/B,MAAM,EAAE,CAeV"}
@@ -0,0 +1,34 @@
1
+ import { type MailError } from './errors';
2
+ import type { Mailer, MailMessage } from './types';
3
+ /** One message the memory mailer accepted, with the id it gave it. */
4
+ export interface MemoryMail extends MailMessage {
5
+ readonly messageId: string;
6
+ }
7
+ /**
8
+ * The reference transport: it keeps what it sends in memory, for tests.
9
+ *
10
+ * It refuses exactly what every transport refuses (it calls
11
+ * {@link checkMessage}), and it can be told to fail, so a test can prove what
12
+ * the application does when a send throws.
13
+ */
14
+ export interface MemoryMailer extends Mailer {
15
+ /** Every message accepted so far, oldest first. A copy: mutating it changes nothing. */
16
+ readonly sent: readonly MemoryMail[];
17
+ /**
18
+ * How many sends reached the hand-over, failed ones included. A message
19
+ * refused as malformed never reaches it. A caller that retries in secret
20
+ * shows up here.
21
+ */
22
+ readonly attempts: number;
23
+ /**
24
+ * Makes the next send that reaches the hand-over reject with `error`, by
25
+ * default a {@link MailFailure} as an outage would. Calls queue: two calls
26
+ * fail the next two sends.
27
+ */
28
+ failNext(error?: MailError): void;
29
+ /** Forgets what was sent, the attempts, and any queued failure. */
30
+ clear(): void;
31
+ }
32
+ /** Creates a {@link MemoryMailer}. Message ids are `memory-1`, `memory-2`, … */
33
+ export declare function createMemoryMailer(): MemoryMailer;
34
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAe,MAAM,UAAU,CAAC;AAEvD,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAY,MAAM,SAAS,CAAC;AAE7D,sEAAsE;AACtE,MAAM,WAAW,UAAW,SAAQ,WAAW;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAa,SAAQ,MAAM;IAC3C,wFAAwF;IACxF,QAAQ,CAAC,IAAI,EAAE,SAAS,UAAU,EAAE,CAAC;IACrC;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAClC,mEAAmE;IACnE,KAAK,IAAI,IAAI,CAAC;CACd;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,IAAI,YAAY,CAyCjD"}
@@ -0,0 +1,23 @@
1
+ import type { Address, MailMessage } from './types';
2
+ /** Every recipient of a message, as bare addresses, in order. */
3
+ export declare function recipientsOf(message: MailMessage): string[];
4
+ /** The bare address of an {@link Address}. */
5
+ export declare function addressOf(address: Address): string;
6
+ /**
7
+ * Refuses a message no transport should hand over, with a {@link MailRefused}
8
+ * that names **where** the problem is and never the value.
9
+ *
10
+ * A transport calls it first thing in `send`, so the refusals are the same
11
+ * whichever transport is wired. It checks:
12
+ *
13
+ * - at least one recipient, each one an address;
14
+ * - `from` and `replyTo`, when present, are addresses;
15
+ * - `subject`, `html` and `text` are strings, and `subject` holds no line
16
+ * break — a line break in a subject is a header injection;
17
+ * - every header name is letters, digits and hyphens, none names what the
18
+ * transport writes from the message (`To`, `Cc`, `Bcc`, `From`, `Sender`,
19
+ * `Reply-To`, `Return-Path`, `Subject`, `MIME-Version`, `Content-*`, in
20
+ * any case), and no header value holds a line break.
21
+ */
22
+ export declare function checkMessage(message: MailMessage): void;
23
+ //# sourceMappingURL=message.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../src/message.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAgBpD,iEAAiE;AACjE,wBAAgB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,EAAE,CAG3D;AAED,8CAA8C;AAC9C,wBAAgB,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAElD;AAuBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CA2CvD"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `@nxgt/mail/renderer` — fills the files `maizzle build` wrote with
3
+ * `@nxgt/mail-i18n`. Its own entry because it reads them with `node:fs`: the
4
+ * main entry, which every transport imports, stays free of Node built-ins.
5
+ */
6
+ import { type WantedLocales } from './locale';
7
+ import type { Rendered } from './types';
8
+ /**
9
+ * A value only known at send time. A number is written as `String(n)`; any
10
+ * other type is refused, so an object never renders as `[object Object]`.
11
+ */
12
+ export type MailVariables = Readonly<Record<string, string | number>>;
13
+ /**
14
+ * The e-mails of a build, each with the variables it takes: the `MailEmails`
15
+ * that `@nxgt/mail-i18n` writes in `generated/mail.ts`.
16
+ */
17
+ export type MailEmailsOf<E> = {
18
+ readonly [K in keyof E]: MailVariables;
19
+ };
20
+ /** What any build takes, when the renderer is not given its `MailEmails`. */
21
+ export type AnyMailEmails = Readonly<Record<string, MailVariables>>;
22
+ /**
23
+ * `render`'s arguments after the e-mail's name: its variables, which may be
24
+ * left out when it takes none, and the options. An e-mail without variables
25
+ * is `Readonly<Record<string, never>>`, as `@nxgt/mail-i18n`'s
26
+ * `rendererTypes()` writes it: change both together.
27
+ */
28
+ export type RenderArguments<V> = Readonly<Record<string, never>> extends V ? [variables?: V, options?: RenderOptions] : [variables: V, options?: RenderOptions];
29
+ export interface MailRendererOptions {
30
+ /** The build's output folder — where `mail-manifest.json` is — as `dist`. */
31
+ readonly dir: string;
32
+ /**
33
+ * The locales wanted, most wanted first, asked at each render — as
34
+ * `@nxgt/i18n`'s language provider: `() => user.locale`, or a Hono
35
+ * handler's `() => c.get('language')`. Picked with `pickLocale`. Default:
36
+ * none, so the fallback locale.
37
+ */
38
+ readonly getLanguage?: () => WantedLocales;
39
+ /** The locale when none wanted is built. Default the manifest's. */
40
+ readonly fallbackLocale?: string;
41
+ }
42
+ export interface RenderOptions {
43
+ /** Render in this locale, one the build wrote, rather than asking `getLanguage`. */
44
+ readonly locale?: string;
45
+ }
46
+ /**
47
+ * Given the build's `MailEmails`, an unknown e-mail, a missing or unknown
48
+ * variable, or a number for a URL is a compile error; without it, any name
49
+ * and any variables compile, and the same mistakes throw.
50
+ */
51
+ export interface MailRenderer<E extends MailEmailsOf<E> = AnyMailEmails> {
52
+ /** The e-mails of the build, sorted, as `['reset-password', 'verify-email']`. */
53
+ readonly emails: readonly (keyof E & string)[];
54
+ /** The locales of the build. */
55
+ readonly locales: readonly string[];
56
+ /**
57
+ * `email` in the wanted locale, every `{{ variable }}` filled: escaped in
58
+ * `html`, as is in `text` and the subject. A missing or unknown variable,
59
+ * an unknown e-mail or locale **throws** an `Error`; a URL variable that
60
+ * is not an `http:`, `https:` or `mailto:` URL throws `MailRefused`.
61
+ */
62
+ render<N extends keyof E & string>(email: N, ...rest: RenderArguments<E[N]>): Rendered;
63
+ }
64
+ /**
65
+ * The run-time renderer: the files `maizzle build` wrote with
66
+ * `@nxgt/mail-i18n`, filled with the values of one send.
67
+ *
68
+ * ```ts
69
+ * import type { MailEmails } from './generated/mail';
70
+ *
71
+ * const mails = createMailRenderer<MailEmails>({ dir: 'dist', getLanguage: () => user.locale });
72
+ * await mailer.send({ to: user.email, ...mails.render('verify-email', { name, link }) });
73
+ * ```
74
+ *
75
+ * Reads the manifest and every file once, here: a missing or broken build
76
+ * **throws** when the renderer is created, not at the first send. A wrong
77
+ * option is a `TypeError`.
78
+ */
79
+ export declare function createMailRenderer<E extends MailEmailsOf<E> = AnyMailEmails>(options: MailRendererOptions): MailRenderer<E>;
80
+ //# sourceMappingURL=renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,OAAO,EAAc,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAC1D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAKxC;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC;AAEtE;;;GAGG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,IAAI;IAAE,QAAQ,EAAE,CAAC,IAAI,MAAM,CAAC,GAAG,aAAa;CAAE,CAAC;AAEzE,6EAA6E;AAC7E,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC;AAEpE;;;;;GAKG;AACH,MAAM,MAAM,eAAe,CAAC,CAAC,IAC5B,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,SAAS,CAAC,GACtC,CAAC,SAAS,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,GACxC,CAAC,SAAS,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,CAAC;AAE5C,MAAM,WAAW,mBAAmB;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,aAAa,CAAC;IAC3C,oEAAoE;IACpE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,MAAM,WAAW,aAAa;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,YAAY,CAAC,CAAC,CAAC,GAAG,aAAa;IACtE,iFAAiF;IACjF,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC,EAAE,CAAC;IAC/C,gCAAgC;IAChC,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC;;;;;OAKG;IACH,MAAM,CAAC,CAAC,SAAS,MAAM,CAAC,GAAG,MAAM,EAChC,KAAK,EAAE,CAAC,EACR,GAAG,IAAI,EAAE,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAC5B,QAAQ,CAAC;CACZ;AA8ND;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,YAAY,CAAC,CAAC,CAAC,GAAG,aAAa,EAC3E,OAAO,EAAE,mBAAmB,GAC1B,YAAY,CAAC,CAAC,CAAC,CAmDjB"}
@@ -0,0 +1,169 @@
1
+ import {
2
+ pickLocale2
3
+ } from "./chunks/index-vq4e9n8f.js";
4
+ import {
5
+ MailRefused2
6
+ } from "./chunks/index-we4n5yfz.js";
7
+
8
+ // src/renderer.ts
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ var MANIFEST_FILE = "mail-manifest.json";
12
+ var PLACEHOLDER = /\{\{\s*([a-z][a-zA-Z0-9]*)\s*\}\}/g;
13
+ var LINE_BREAKS = /[\r\n\v\f\u0085\u2028\u2029]+/g;
14
+ var SAFE_URL = /^(?:https?:\/\/|mailto:)/i;
15
+ var hasUnsafeUrlChar = (value) => [...value].some((char) => {
16
+ const code = char.codePointAt(0) ?? 0;
17
+ return code < 33 || code === 127 || /[\s"'<>`]/.test(char);
18
+ });
19
+ var HTML_ESCAPES = {
20
+ "&": "&amp;",
21
+ "<": "&lt;",
22
+ ">": "&gt;",
23
+ '"': "&quot;",
24
+ "'": "&#39;"
25
+ };
26
+ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char);
27
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
28
+ var isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
29
+ function checkOptions(options) {
30
+ if (!isObject(options)) {
31
+ throw new TypeError("createMailRenderer: options must be an object, as { dir: 'dist' }");
32
+ }
33
+ if (typeof options.dir !== "string" || options.dir.trim() === "") {
34
+ throw new TypeError("createMailRenderer: dir must be the folder maizzle build wrote, as dist");
35
+ }
36
+ if (options.getLanguage !== undefined && typeof options.getLanguage !== "function") {
37
+ throw new TypeError("createMailRenderer: getLanguage must be a function that answers the wanted locales, as () => user.locale");
38
+ }
39
+ }
40
+ function readBuilt(dir, path) {
41
+ try {
42
+ return readFileSync(join(dir, path), "utf8");
43
+ } catch (error) {
44
+ throw new Error(`createMailRenderer: ${join(dir, path)} cannot be read — run maizzle build, and deploy its output folder`, { cause: error });
45
+ }
46
+ }
47
+ function readManifest(dir) {
48
+ const file = join(dir, MANIFEST_FILE);
49
+ let manifest;
50
+ try {
51
+ manifest = JSON.parse(readBuilt(dir, MANIFEST_FILE));
52
+ } catch (error) {
53
+ if (error instanceof SyntaxError) {
54
+ throw new Error(`createMailRenderer: ${file} is not valid JSON`, {
55
+ cause: error
56
+ });
57
+ }
58
+ throw error;
59
+ }
60
+ if (!isObject(manifest) || !isStringList(manifest.locales) || manifest.locales.length === 0 || typeof manifest.fallbackLocale !== "string" || !isObject(manifest.emails)) {
61
+ throw new Error(`createMailRenderer: ${file} is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin`);
62
+ }
63
+ return {
64
+ locales: manifest.locales,
65
+ fallbackLocale: manifest.fallbackLocale,
66
+ emails: manifest.emails
67
+ };
68
+ }
69
+ function readEmail(dir, name, entry, locales) {
70
+ const broken = () => new Error(`createMailRenderer: ${MANIFEST_FILE} describes ${name} in a shape this version does not read — rebuild with the same version of @nxgt/mail-i18n`);
71
+ if (!isObject(entry) || !isStringList(entry.variables) || !isStringList(entry.urlVariables) || !isObject(entry.subject) || !isObject(entry.files)) {
72
+ throw broken();
73
+ }
74
+ const parts = new Map;
75
+ for (const locale of locales) {
76
+ const subject = entry.subject[locale];
77
+ const files = entry.files[locale];
78
+ if (typeof subject !== "string" || !isObject(files) || typeof files.html !== "string") {
79
+ throw broken();
80
+ }
81
+ if (typeof files.text !== "string") {
82
+ throw new Error(`createMailRenderer: ${name} has no text part in ${locale} — keep Maizzle's plaintext on, as @nxgt/mail-config sets it`);
83
+ }
84
+ parts.set(locale, {
85
+ subject,
86
+ html: readBuilt(dir, files.html),
87
+ text: readBuilt(dir, files.text)
88
+ });
89
+ }
90
+ return {
91
+ variables: new Set(entry.variables),
92
+ urlVariables: new Set(entry.urlVariables),
93
+ locales: parts
94
+ };
95
+ }
96
+ function checkVariables(name, email, variables) {
97
+ if (!isObject(variables)) {
98
+ throw new TypeError(`render: the variables of ${name} must be an object, as { name: 'Ada' }`);
99
+ }
100
+ const values = new Map;
101
+ for (const [key, value] of Object.entries(variables)) {
102
+ if (!email.variables.has(key)) {
103
+ throw new Error(`render: ${name} has no variable ${key} — it takes ${[...email.variables].join(", ") || "none"}`);
104
+ }
105
+ if (typeof value === "number" && Number.isFinite(value)) {
106
+ values.set(key, String(value));
107
+ } else if (typeof value === "string") {
108
+ values.set(key, value);
109
+ } else {
110
+ throw new TypeError(`render: ${name}: ${key} must be a string or a finite number`);
111
+ }
112
+ }
113
+ for (const key of email.variables) {
114
+ const value = values.get(key);
115
+ if (value === undefined) {
116
+ throw new Error(`render: ${name} needs the variable ${key}`);
117
+ }
118
+ if (email.urlVariables.has(key) && (!SAFE_URL.test(value) || hasUnsafeUrlChar(value) || !URL.canParse(value))) {
119
+ throw new MailRefused2(`render: ${name}: ${key} must be an http:, https: or mailto: URL`);
120
+ }
121
+ }
122
+ return values;
123
+ }
124
+ var fill = (source, values, write) => source.replace(PLACEHOLDER, (mark, key) => {
125
+ const value = values.get(key);
126
+ return value === undefined ? mark : write(value);
127
+ });
128
+ function createMailRenderer(options) {
129
+ checkOptions(options);
130
+ const { dir } = options;
131
+ const manifest = readManifest(dir);
132
+ const fallback = options.fallbackLocale ?? manifest.fallbackLocale;
133
+ if (!manifest.locales.includes(fallback)) {
134
+ throw new TypeError(`createMailRenderer: fallbackLocale must be one of the build's locales, ${manifest.locales.join(", ")}`);
135
+ }
136
+ const emails = new Map;
137
+ for (const [name, entry] of Object.entries(manifest.emails)) {
138
+ emails.set(name, readEmail(dir, name, entry, manifest.locales));
139
+ }
140
+ const getLanguage = options.getLanguage;
141
+ const renderer = Object.freeze({
142
+ emails: Object.freeze([...emails.keys()].sort()),
143
+ locales: Object.freeze([...manifest.locales]),
144
+ render(name, variables = {}, renderOptions = {}) {
145
+ const email = emails.get(name);
146
+ if (email === undefined) {
147
+ throw new Error(`render: ${String(name)} is not an e-mail of the build — one of ${[...emails.keys()].sort().join(", ")}`);
148
+ }
149
+ const locale = renderOptions.locale ?? pickLocale2(getLanguage?.(), manifest.locales, fallback);
150
+ const parts = email.locales.get(locale);
151
+ if (parts === undefined) {
152
+ throw new Error(`render: the locale asked for is not one of the build's, ${manifest.locales.join(", ")}`);
153
+ }
154
+ const values = checkVariables(name, email, variables);
155
+ return {
156
+ subject: fill(parts.subject, values, (value) => value).replace(LINE_BREAKS, " ").trim(),
157
+ html: fill(parts.html, values, escapeHtml),
158
+ text: fill(parts.text, values, (value) => value)
159
+ };
160
+ }
161
+ });
162
+ return renderer;
163
+ }
164
+ export {
165
+ createMailRenderer
166
+ };
167
+
168
+ //# debugId=74229904C7DC09F264756E2164756E21
169
+ //# sourceMappingURL=renderer.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/renderer.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * `@nxgt/mail/renderer` — fills the files `maizzle build` wrote with\n * `@nxgt/mail-i18n`. Its own entry because it reads them with `node:fs`: the\n * main entry, which every transport imports, stays free of Node built-ins.\n */\n\nimport { readFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { MailRefused } from './errors';\nimport { pickLocale, type WantedLocales } from './locale';\nimport type { Rendered } from './types';\n\n/** The manifest's name in the build's output folder, as `@nxgt/mail-i18n` writes it. */\nconst MANIFEST_FILE = 'mail-manifest.json';\n\n/**\n * A value only known at send time. A number is written as `String(n)`; any\n * other type is refused, so an object never renders as `[object Object]`.\n */\nexport type MailVariables = Readonly<Record<string, string | number>>;\n\n/**\n * The e-mails of a build, each with the variables it takes: the `MailEmails`\n * that `@nxgt/mail-i18n` writes in `generated/mail.ts`.\n */\nexport type MailEmailsOf<E> = { readonly [K in keyof E]: MailVariables };\n\n/** What any build takes, when the renderer is not given its `MailEmails`. */\nexport type AnyMailEmails = Readonly<Record<string, MailVariables>>;\n\n/**\n * `render`'s arguments after the e-mail's name: its variables, which may be\n * left out when it takes none, and the options. An e-mail without variables\n * is `Readonly<Record<string, never>>`, as `@nxgt/mail-i18n`'s\n * `rendererTypes()` writes it: change both together.\n */\nexport type RenderArguments<V> =\n\tReadonly<Record<string, never>> extends V\n\t\t? [variables?: V, options?: RenderOptions]\n\t\t: [variables: V, options?: RenderOptions];\n\nexport interface MailRendererOptions {\n\t/** The build's output folder — where `mail-manifest.json` is — as `dist`. */\n\treadonly dir: string;\n\t/**\n\t * The locales wanted, most wanted first, asked at each render — as\n\t * `@nxgt/i18n`'s language provider: `() => user.locale`, or a Hono\n\t * handler's `() => c.get('language')`. Picked with `pickLocale`. Default:\n\t * none, so the fallback locale.\n\t */\n\treadonly getLanguage?: () => WantedLocales;\n\t/** The locale when none wanted is built. Default the manifest's. */\n\treadonly fallbackLocale?: string;\n}\n\nexport interface RenderOptions {\n\t/** Render in this locale, one the build wrote, rather than asking `getLanguage`. */\n\treadonly locale?: string;\n}\n\n/**\n * Given the build's `MailEmails`, an unknown e-mail, a missing or unknown\n * variable, or a number for a URL is a compile error; without it, any name\n * and any variables compile, and the same mistakes throw.\n */\nexport interface MailRenderer<E extends MailEmailsOf<E> = AnyMailEmails> {\n\t/** The e-mails of the build, sorted, as `['reset-password', 'verify-email']`. */\n\treadonly emails: readonly (keyof E & string)[];\n\t/** The locales of the build. */\n\treadonly locales: readonly string[];\n\t/**\n\t * `email` in the wanted locale, every `{{ variable }}` filled: escaped in\n\t * `html`, as is in `text` and the subject. A missing or unknown variable,\n\t * an unknown e-mail or locale **throws** an `Error`; a URL variable that\n\t * is not an `http:`, `https:` or `mailto:` URL throws `MailRefused`.\n\t */\n\trender<N extends keyof E & string>(\n\t\temail: N,\n\t\t...rest: RenderArguments<E[N]>\n\t): Rendered;\n}\n\n/** One e-mail in one locale, read. */\ninterface Parts {\n\treadonly subject: string;\n\treadonly html: string;\n\treadonly text: string;\n}\n\ninterface Email {\n\treadonly variables: ReadonlySet<string>;\n\treadonly urlVariables: ReadonlySet<string>;\n\treadonly locales: ReadonlyMap<string, Parts>;\n}\n\n/**\n * `{{ name }}` — what `@nxgt/mail-i18n`'s `placeholder('name')` writes. A\n * copy of `PLACEHOLDER` in `packages/mail-i18n/src/manifest.ts`, as is the\n * manifest's shape below: change both, or a declared variable goes unfilled.\n */\nconst PLACEHOLDER = /\\{\\{\\s*([a-z][a-zA-Z0-9]*)\\s*\\}\\}/g;\n\n/** A line break a header would split on: each run becomes one space. */\nconst LINE_BREAKS = /[\\r\\n\\v\\f\\u0085\\u2028\\u2029]+/g;\n\n/** What a URL variable may hold: a scheme a mail client cannot run. */\nconst SAFE_URL = /^(?:https?:\\/\\/|mailto:)/i;\n\n/** Whitespace, a control, a quote or a bracket: what no URL holds as is. */\nconst hasUnsafeUrlChar = (value: string) =>\n\t[...value].some((char) => {\n\t\tconst code = char.codePointAt(0) ?? 0;\n\t\treturn code < 0x21 || code === 0x7f || /[\\s\"'<>`]/.test(char);\n\t});\n\nconst HTML_ESCAPES: Readonly<Record<string, string>> = {\n\t'&': '&amp;',\n\t'<': '&lt;',\n\t'>': '&gt;',\n\t'\"': '&quot;',\n\t\"'\": '&#39;',\n};\n\nconst escapeHtml = (value: string) =>\n\tvalue.replace(/[&<>\"']/g, (char) => HTML_ESCAPES[char] ?? char);\n\nconst isObject = (value: unknown): value is Record<string, unknown> =>\n\ttypeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst isStringList = (value: unknown): value is string[] =>\n\tArray.isArray(value) && value.every((item) => typeof item === 'string');\n\nfunction checkOptions(options: MailRendererOptions): void {\n\tif (!isObject(options)) {\n\t\tthrow new TypeError(\n\t\t\t\"createMailRenderer: options must be an object, as { dir: 'dist' }\",\n\t\t);\n\t}\n\tif (typeof options.dir !== 'string' || options.dir.trim() === '') {\n\t\tthrow new TypeError(\n\t\t\t'createMailRenderer: dir must be the folder maizzle build wrote, as dist',\n\t\t);\n\t}\n\tif (\n\t\toptions.getLanguage !== undefined &&\n\t\ttypeof options.getLanguage !== 'function'\n\t) {\n\t\tthrow new TypeError(\n\t\t\t'createMailRenderer: getLanguage must be a function that answers the wanted locales, as () => user.locale',\n\t\t);\n\t}\n}\n\n/** Reads `dir/path`; a missing file **throws**, naming it. */\nfunction readBuilt(dir: string, path: string): string {\n\ttry {\n\t\treturn readFileSync(join(dir, path), 'utf8');\n\t} catch (error) {\n\t\tthrow new Error(\n\t\t\t`createMailRenderer: ${join(dir, path)} cannot be read — run maizzle build, and deploy its output folder`,\n\t\t\t{ cause: error },\n\t\t);\n\t}\n}\n\n/** The manifest, its shape checked: a broken one **throws**. */\nfunction readManifest(dir: string) {\n\tconst file = join(dir, MANIFEST_FILE);\n\tlet manifest: unknown;\n\ttry {\n\t\tmanifest = JSON.parse(readBuilt(dir, MANIFEST_FILE));\n\t} catch (error) {\n\t\tif (error instanceof SyntaxError) {\n\t\t\tthrow new Error(`createMailRenderer: ${file} is not valid JSON`, {\n\t\t\t\tcause: error,\n\t\t\t});\n\t\t}\n\t\tthrow error;\n\t}\n\tif (\n\t\t!isObject(manifest) ||\n\t\t!isStringList(manifest.locales) ||\n\t\tmanifest.locales.length === 0 ||\n\t\ttypeof manifest.fallbackLocale !== 'string' ||\n\t\t!isObject(manifest.emails)\n\t) {\n\t\tthrow new Error(\n\t\t\t`createMailRenderer: ${file} is not a manifest of @nxgt/mail-i18n — build with its i18n() plugin`,\n\t\t);\n\t}\n\treturn {\n\t\tlocales: manifest.locales,\n\t\tfallbackLocale: manifest.fallbackLocale,\n\t\temails: manifest.emails,\n\t};\n}\n\n/** One e-mail of the manifest, with its files read in every locale. */\nfunction readEmail(\n\tdir: string,\n\tname: string,\n\tentry: unknown,\n\tlocales: readonly string[],\n): Email {\n\tconst broken = () =>\n\t\tnew Error(\n\t\t\t`createMailRenderer: ${MANIFEST_FILE} describes ${name} in a shape this version does not read — rebuild with the same version of @nxgt/mail-i18n`,\n\t\t);\n\tif (\n\t\t!isObject(entry) ||\n\t\t!isStringList(entry.variables) ||\n\t\t!isStringList(entry.urlVariables) ||\n\t\t!isObject(entry.subject) ||\n\t\t!isObject(entry.files)\n\t) {\n\t\tthrow broken();\n\t}\n\tconst parts = new Map<string, Parts>();\n\tfor (const locale of locales) {\n\t\tconst subject = entry.subject[locale];\n\t\tconst files = entry.files[locale];\n\t\tif (\n\t\t\ttypeof subject !== 'string' ||\n\t\t\t!isObject(files) ||\n\t\t\ttypeof files.html !== 'string'\n\t\t) {\n\t\t\tthrow broken();\n\t\t}\n\t\tif (typeof files.text !== 'string') {\n\t\t\tthrow new Error(\n\t\t\t\t`createMailRenderer: ${name} has no text part in ${locale} — keep Maizzle's plaintext on, as @nxgt/mail-config sets it`,\n\t\t\t);\n\t\t}\n\t\tparts.set(locale, {\n\t\t\tsubject,\n\t\t\thtml: readBuilt(dir, files.html),\n\t\t\ttext: readBuilt(dir, files.text),\n\t\t});\n\t}\n\treturn {\n\t\tvariables: new Set(entry.variables),\n\t\turlVariables: new Set(entry.urlVariables),\n\t\tlocales: parts,\n\t};\n}\n\n/** Each variable of `email` as a string, checked against what it declares. */\nfunction checkVariables(\n\tname: string,\n\temail: Email,\n\tvariables: unknown,\n): Map<string, string> {\n\tif (!isObject(variables)) {\n\t\tthrow new TypeError(\n\t\t\t`render: the variables of ${name} must be an object, as { name: 'Ada' }`,\n\t\t);\n\t}\n\tconst values = new Map<string, string>();\n\tfor (const [key, value] of Object.entries(variables)) {\n\t\tif (!email.variables.has(key)) {\n\t\t\tthrow new Error(\n\t\t\t\t`render: ${name} has no variable ${key} — it takes ${[...email.variables].join(', ') || 'none'}`,\n\t\t\t);\n\t\t}\n\t\tif (typeof value === 'number' && Number.isFinite(value)) {\n\t\t\tvalues.set(key, String(value));\n\t\t} else if (typeof value === 'string') {\n\t\t\tvalues.set(key, value);\n\t\t} else {\n\t\t\tthrow new TypeError(\n\t\t\t\t`render: ${name}: ${key} must be a string or a finite number`,\n\t\t\t);\n\t\t}\n\t}\n\tfor (const key of email.variables) {\n\t\tconst value = values.get(key);\n\t\tif (value === undefined) {\n\t\t\tthrow new Error(`render: ${name} needs the variable ${key}`);\n\t\t}\n\t\tif (\n\t\t\temail.urlVariables.has(key) &&\n\t\t\t(!SAFE_URL.test(value) || hasUnsafeUrlChar(value) || !URL.canParse(value))\n\t\t) {\n\t\t\tthrow new MailRefused(\n\t\t\t\t`render: ${name}: ${key} must be an http:, https: or mailto: URL`,\n\t\t\t);\n\t\t}\n\t}\n\treturn values;\n}\n\n/** `source` with each placeholder replaced, in one pass: a value is never read again. */\nconst fill = (\n\tsource: string,\n\tvalues: ReadonlyMap<string, string>,\n\twrite: (value: string) => string,\n) =>\n\tsource.replace(PLACEHOLDER, (mark, key: string) => {\n\t\tconst value = values.get(key);\n\t\treturn value === undefined ? mark : write(value);\n\t});\n\n/**\n * The run-time renderer: the files `maizzle build` wrote with\n * `@nxgt/mail-i18n`, filled with the values of one send.\n *\n * ```ts\n * import type { MailEmails } from './generated/mail';\n *\n * const mails = createMailRenderer<MailEmails>({ dir: 'dist', getLanguage: () => user.locale });\n * await mailer.send({ to: user.email, ...mails.render('verify-email', { name, link }) });\n * ```\n *\n * Reads the manifest and every file once, here: a missing or broken build\n * **throws** when the renderer is created, not at the first send. A wrong\n * option is a `TypeError`.\n */\nexport function createMailRenderer<E extends MailEmailsOf<E> = AnyMailEmails>(\n\toptions: MailRendererOptions,\n): MailRenderer<E> {\n\tcheckOptions(options);\n\tconst { dir } = options;\n\tconst manifest = readManifest(dir);\n\tconst fallback = options.fallbackLocale ?? manifest.fallbackLocale;\n\tif (!manifest.locales.includes(fallback)) {\n\t\tthrow new TypeError(\n\t\t\t`createMailRenderer: fallbackLocale must be one of the build's locales, ${manifest.locales.join(', ')}`,\n\t\t);\n\t}\n\tconst emails = new Map<string, Email>();\n\tfor (const [name, entry] of Object.entries(manifest.emails)) {\n\t\temails.set(name, readEmail(dir, name, entry, manifest.locales));\n\t}\n\tconst getLanguage = options.getLanguage;\n\n\tconst renderer: MailRenderer = Object.freeze({\n\t\temails: Object.freeze([...emails.keys()].sort()),\n\t\tlocales: Object.freeze([...manifest.locales]),\n\t\trender(\n\t\t\tname: string,\n\t\t\tvariables: MailVariables = {},\n\t\t\trenderOptions: RenderOptions = {},\n\t\t): Rendered {\n\t\t\tconst email = emails.get(name);\n\t\t\tif (email === undefined) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`render: ${String(name)} is not an e-mail of the build — one of ${[...emails.keys()].sort().join(', ')}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst locale =\n\t\t\t\trenderOptions.locale ??\n\t\t\t\tpickLocale(getLanguage?.(), manifest.locales, fallback);\n\t\t\tconst parts = email.locales.get(locale);\n\t\t\tif (parts === undefined) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`render: the locale asked for is not one of the build's, ${manifest.locales.join(', ')}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst values = checkVariables(name, email, variables);\n\t\t\treturn {\n\t\t\t\tsubject: fill(parts.subject, values, (value) => value)\n\t\t\t\t\t.replace(LINE_BREAKS, ' ')\n\t\t\t\t\t.trim(),\n\t\t\t\thtml: fill(parts.html, values, escapeHtml),\n\t\t\t\ttext: fill(parts.text, values, (value) => value),\n\t\t\t};\n\t\t},\n\t});\n\t// The types only narrow what the same checks refuse at run time.\n\treturn renderer as unknown as MailRenderer<E>;\n}\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "74229904C7DC09F264756E2164756E21",
9
+ "names": []
10
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * An e-mail address: bare (`ada@example.com`), or with the name a mail client
3
+ * shows beside it.
4
+ *
5
+ * A string is **only** an address — `"Ada <ada@example.com>"` is refused, and
6
+ * so is a string holding whitespace, `,`, `;` or `:` — so a transport never
7
+ * has to parse one, and a parser that does finds one mailbox. A name is free text (`Doe, John` is a
8
+ * name), refused only when it holds a line break; **quoting or encoding it
9
+ * is the transport's job**, and the conformance case `send.hostileName`
10
+ * fails a transport whose name lets a second recipient through.
11
+ */
12
+ export type Address = string | {
13
+ readonly name: string;
14
+ readonly address: string;
15
+ };
16
+ /**
17
+ * The three parts of one e-mail, in one locale, with every value already
18
+ * filled in and escaped: what the run-time renderer answers
19
+ * (`createMailRenderer(…).render(…)`), or any hand-written function answering the
20
+ * same shape — escaping is then that function's job.
21
+ */
22
+ export interface Rendered {
23
+ readonly subject: string;
24
+ readonly html: string;
25
+ readonly text: string;
26
+ }
27
+ /**
28
+ * A rendered e-mail, addressed. What a {@link Mailer} sends.
29
+ *
30
+ * `from` is optional because a transport is usually wired with a default
31
+ * sender; a transport with none refuses a message without one.
32
+ */
33
+ export interface MailMessage extends Rendered {
34
+ readonly to: Address | readonly Address[];
35
+ readonly from?: Address;
36
+ readonly replyTo?: Address;
37
+ /**
38
+ * Extra headers, such as `List-Unsubscribe`. A name is letters, digits and
39
+ * hyphens; neither a name nor a value may hold a line break. What the
40
+ * transport writes from the message — `To`, `Cc`, `Bcc`, `From`, `Sender`,
41
+ * `Reply-To`, `Return-Path`, `Subject`, `MIME-Version`, `Content-*`, in any
42
+ * case — is refused.
43
+ */
44
+ readonly headers?: Readonly<Record<string, string>>;
45
+ }
46
+ /** What a transport answers once it has handed a message over. */
47
+ export interface SentMail {
48
+ /**
49
+ * The id the transport gave the message, or `null` when it gives none. An
50
+ * absence, not a failure: a failure throws.
51
+ */
52
+ readonly messageId: string | null;
53
+ }
54
+ /**
55
+ * The port every transport implements.
56
+ *
57
+ * **A failure throws; it never answers.** `send` resolves only once the
58
+ * transport has accepted the message. Otherwise it rejects with a
59
+ * {@link MailFailure} (the transport could not be reached or did not answer)
60
+ * or a {@link MailRefused} (the message itself was refused). It never resolves
61
+ * `false`, and it never logs and resolves: a caller that maps a failed send to
62
+ * "sent" has told a user to check an inbox that will stay empty.
63
+ *
64
+ * `@nxgt/mail/conformance` checks a transport against this contract.
65
+ */
66
+ export interface Mailer {
67
+ send(message: MailMessage): Promise<SentMail>;
68
+ }
69
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,OAAO,GAChB,MAAM,GACN;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvD;;;;;GAKG;AACH,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAY,SAAQ,QAAQ;IAC5C,QAAQ,CAAC,EAAE,EAAE,OAAO,GAAG,SAAS,OAAO,EAAE,CAAC;IAC1C,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACpD;AAED,kEAAkE;AAClE,MAAM,WAAW,QAAQ;IACxB;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,MAAM;IACtB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC9C"}
package/docs/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @nxgt/mail — documentation
2
+
3
+ The [README](../README.md) shows that it works; these pages show how, one area
4
+ at a time, with an example for every option. The words they use — e-mail,
5
+ mailer, transport, hand-over, refusal, failure — are defined once, in the
6
+ [vocabulary](https://github.com/softistx/nxgt-mail/blob/develop/docs/vocabulary.md).
7
+
8
+ | Page | Read it when |
9
+ | --- | --- |
10
+ | [Rendering](guide/rendering.md) | You are turning a Maizzle build of `@nxgt/mail-i18n` into an e-mail with `createMailRenderer`: its options, typing it with the build's `MailEmails`, choosing the locale, escaping, URL variables, deploying the build, and every error |
11
+ | [Sending](guide/sending.md) | You are calling `mailer.send`: the `MailMessage` shape, addresses, headers, what `send` answers, and turning `MailFailure` and `MailRefused` into a response |
12
+ | [Testing](guide/testing.md) | You are testing code that sends e-mail with `createMemoryMailer`: reading the outbox, making a send fail, counting attempts |
13
+ | [Locales](guide/locales.md) | You are choosing the locale an e-mail is rendered in, with `pickLocale` and `parseAcceptLanguage` |
14
+ | [Writing a transport](guide/transports.md) | You are implementing the `Mailer` port for a provider, and running `@nxgt/mail/conformance` against it |
15
+ | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
16
+ | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |