@ultimat3/mail 1.0.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.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/package.json +39 -0
- package/src/base64.ts +9 -0
- package/src/blocks.d.ts +47 -0
- package/src/blocks.d.ts.map +1 -0
- package/src/blocks.js +23 -0
- package/src/blocks.js.map +1 -0
- package/src/blocks.ts +56 -0
- package/src/catalog.d.ts +6 -0
- package/src/catalog.d.ts.map +1 -0
- package/src/catalog.js +78 -0
- package/src/catalog.js.map +1 -0
- package/src/catalog.ts +88 -0
- package/src/driver-env.ts +105 -0
- package/src/driver-resend.ts +184 -0
- package/src/driver-smtp.ts +209 -0
- package/src/driver.d.ts +79 -0
- package/src/driver.d.ts.map +1 -0
- package/src/driver.js +115 -0
- package/src/driver.js.map +1 -0
- package/src/driver.ts +174 -0
- package/src/errors.d.ts +23 -0
- package/src/errors.d.ts.map +1 -0
- package/src/errors.js +82 -0
- package/src/errors.js.map +1 -0
- package/src/errors.ts +177 -0
- package/src/html.d.ts +10 -0
- package/src/html.d.ts.map +1 -0
- package/src/html.js +34 -0
- package/src/html.js.map +1 -0
- package/src/html.ts +37 -0
- package/src/idempotency.ts +61 -0
- package/src/index.d.ts +17 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +13 -0
- package/src/index.js.map +1 -0
- package/src/index.ts +110 -0
- package/src/job.d.ts +17 -0
- package/src/job.d.ts.map +1 -0
- package/src/job.js +79 -0
- package/src/job.js.map +1 -0
- package/src/job.ts +36 -0
- package/src/layout.d.ts +41 -0
- package/src/layout.d.ts.map +1 -0
- package/src/layout.js +160 -0
- package/src/layout.js.map +1 -0
- package/src/layout.ts +221 -0
- package/src/mail.d.ts +67 -0
- package/src/mail.d.ts.map +1 -0
- package/src/mail.js +119 -0
- package/src/mail.js.map +1 -0
- package/src/mail.ts +195 -0
- package/src/mime.ts +233 -0
- package/src/render.d.ts +26 -0
- package/src/render.d.ts.map +1 -0
- package/src/render.js +149 -0
- package/src/render.js.map +1 -0
- package/src/render.ts +190 -0
- package/src/smtp-client.ts +246 -0
- package/src/smtp-protocol.ts +184 -0
- package/src/smtp-socket.ts +266 -0
- package/src/templates/index.d.ts +9 -0
- package/src/templates/index.d.ts.map +1 -0
- package/src/templates/index.js +23 -0
- package/src/templates/index.js.map +1 -0
- package/src/templates/index.ts +39 -0
- package/src/templates/invite.d.ts +15 -0
- package/src/templates/invite.d.ts.map +1 -0
- package/src/templates/invite.js +25 -0
- package/src/templates/invite.js.map +1 -0
- package/src/templates/invite.ts +29 -0
- package/src/templates/mfa-enrolled.d.ts +14 -0
- package/src/templates/mfa-enrolled.d.ts.map +1 -0
- package/src/templates/mfa-enrolled.js +26 -0
- package/src/templates/mfa-enrolled.js.map +1 -0
- package/src/templates/mfa-enrolled.ts +37 -0
- package/src/templates/reset-password.d.ts +13 -0
- package/src/templates/reset-password.d.ts.map +1 -0
- package/src/templates/reset-password.js +23 -0
- package/src/templates/reset-password.js.map +1 -0
- package/src/templates/reset-password.ts +27 -0
- package/src/templates/security-alert.d.ts +16 -0
- package/src/templates/security-alert.d.ts.map +1 -0
- package/src/templates/security-alert.js +29 -0
- package/src/templates/security-alert.js.map +1 -0
- package/src/templates/security-alert.ts +36 -0
- package/src/templates/verify-email.d.ts +13 -0
- package/src/templates/verify-email.d.ts.map +1 -0
- package/src/templates/verify-email.js +23 -0
- package/src/templates/verify-email.js.map +1 -0
- package/src/templates/verify-email.ts +27 -0
- package/src/templates/welcome.d.ts +13 -0
- package/src/templates/welcome.d.ts.map +1 -0
- package/src/templates/welcome.js +20 -0
- package/src/templates/welcome.js.map +1 -0
- package/src/templates/welcome.ts +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 developerz.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# @ultimat3/mail ✉️
|
|
2
|
+
|
|
3
|
+
Transactional email as data. One template renders **both** an HTML part and a plain-text part,
|
|
4
|
+
every string is an i18n key, every colour is a design token, and delivery is a job.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { defineMail, send, blocks, t } from '@ultimat3/mail';
|
|
8
|
+
|
|
9
|
+
export const receiptMail = defineMail({
|
|
10
|
+
id: 'receipt',
|
|
11
|
+
subject: 'mail.receipt.subject', // an i18n key, never a literal
|
|
12
|
+
input: t.object({ name: t.string, url: t.url }),
|
|
13
|
+
template: ({ data }) => [
|
|
14
|
+
blocks.heading('mail.receipt.heading', { name: data.name }),
|
|
15
|
+
blocks.paragraph('mail.receipt.body'),
|
|
16
|
+
blocks.button('mail.receipt.cta', data.url),
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await send(receiptMail, { name: user.name, url }, { to: user.email, locale: ctx.locale });
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`send` validates `data` through the mail's schema, renders, then enqueues `mail.send`. It
|
|
24
|
+
delivers inline only when `{ sync: true }` is passed or no job driver is configured.
|
|
25
|
+
|
|
26
|
+
## Rules
|
|
27
|
+
|
|
28
|
+
| Rule | Why |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `locale` is required by the **type** | a mail is read hours later; there is no ambient request locale to fall back to. `X_MAIL_LOCALE_MISSING` is the backstop for JS callers |
|
|
31
|
+
| Text part is mandatory | HTML-only mail scores as spam and is unreadable to screen readers. Empty text ⇒ `X_MAIL_TEXT_MISSING` |
|
|
32
|
+
| Text is derived from blocks | never scraped out of the HTML, so the two parts cannot drift |
|
|
33
|
+
| Every string is a key | `mail.<id>.<slot>`; English lives in `src/catalog.ts` and app catalogs override it |
|
|
34
|
+
| Every colour is a token | `MAIL_TOKENS` in `layout.ts` holds light + dark hexes; templates never see a hex |
|
|
35
|
+
| Every date takes an IANA zone | `options.tz`, else `ctx.tz`, else `UTC` |
|
|
36
|
+
| Sending is a job | `retry: { attempts: 5, backoff: 'exponential' }`, idempotency key derived from `(mailId, recipients, hash(rendered))` |
|
|
37
|
+
|
|
38
|
+
## Drivers
|
|
39
|
+
|
|
40
|
+
`setMailDriver(driver)` once at boot. Asking for one that was never set is
|
|
41
|
+
`X_MAIL_DRIVER_UNAVAILABLE`.
|
|
42
|
+
|
|
43
|
+
| Driver | Use | Behaviour |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `createMemoryDriver()` | dev, tests | retains messages; `outbox()` / `lastTo()` feed the `/_x` mail panel |
|
|
46
|
+
| `createLogDriver()` | workers without credentials | one structured line per message through core's `logger`; bodies never logged |
|
|
47
|
+
| `createSmtpDriver({ url, from })` | prod | real ESMTP over `Bun.connect`: STARTTLS, `AUTH PLAIN`/`LOGIN`, quoted-printable MIME |
|
|
48
|
+
| `createResendDriver({ apiKey, from })` | prod | one `POST /emails`, `Idempotency-Key` on every request |
|
|
49
|
+
|
|
50
|
+
### Which one a boot installs
|
|
51
|
+
|
|
52
|
+
`selectMailDriver(env)` is the one answer, and `x dev` calls it — an unset variable means the
|
|
53
|
+
embedded default, the same law the database, event bus and storage bindings follow. Nothing about
|
|
54
|
+
the app changes between environments; the credential does.
|
|
55
|
+
|
|
56
|
+
| env | driver |
|
|
57
|
+
|---|---|
|
|
58
|
+
| *(nothing set)* | `createMemoryDriver()` — caught, never sent |
|
|
59
|
+
| `SMTP_URL` + `MAIL_FROM` | `createSmtpDriver(...)`, `MAIL_POOL_SIZE` optional |
|
|
60
|
+
| `RESEND_API_KEY` + `MAIL_FROM` | `createResendDriver(...)` |
|
|
61
|
+
|
|
62
|
+
Both credentials at once is `X_CONFIG_INVALID` rather than a winner picked for you, and a
|
|
63
|
+
transport with no `MAIL_FROM` is refused at boot instead of on the first send. A host that is not
|
|
64
|
+
`x dev` calls `selectMailDriver` itself, or constructs a driver directly — `setMailDriver` is the
|
|
65
|
+
only seam either way.
|
|
66
|
+
|
|
67
|
+
### SMTP
|
|
68
|
+
|
|
69
|
+
`smtps://user:pass@host:465` is implicit TLS; `smtp://host:587` starts in the clear and upgrades
|
|
70
|
+
with STARTTLS. Credentials are percent-decoded, so a password with `@` or `/` works.
|
|
71
|
+
|
|
72
|
+
| Rule | Why |
|
|
73
|
+
|---|---|
|
|
74
|
+
| A server offering no STARTTLS is refused | the message *and* the password would cross in the clear. `allowInsecure: true` is the explicit opt-out |
|
|
75
|
+
| Capabilities are re-read after STARTTLS | most servers only advertise `AUTH` once encrypted, and a cleartext EHLO can be stripped in flight |
|
|
76
|
+
| Any rejected recipient fails the send | delivering to three of four addresses and reporting success is the one outcome a caller cannot detect |
|
|
77
|
+
| `poolSize` (default 4) caps concurrent connections | a burst of sends queues instead of opening one socket each |
|
|
78
|
+
| `Bcc` never reaches a header | it travels in `RCPT TO` only |
|
|
79
|
+
| The reported `id` is the `Message-ID` | an SMTP `250` carries nothing a caller could correlate. It is not derived from the idempotency key, which holds the recipient list |
|
|
80
|
+
|
|
81
|
+
### Resend
|
|
82
|
+
|
|
83
|
+
`RESEND_API_KEY` and a verified sending domain. Every request carries `Idempotency-Key:
|
|
84
|
+
mailIdempotencyKey(message)` — a job retry after a timeout hands Resend the identical message, and
|
|
85
|
+
that header is what makes it one email. 408/409/425/429 and 5xx are retryable; every other non-2xx
|
|
86
|
+
is a configuration problem that retrying cannot fix.
|
|
87
|
+
|
|
88
|
+
## Framework mails
|
|
89
|
+
|
|
90
|
+
Registered by importing them. `FRAMEWORK_MAILS` is the list `x mail list` prints.
|
|
91
|
+
|
|
92
|
+
| id | Input |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `welcome` | `{ name, appName, url }` |
|
|
95
|
+
| `verify-email` | `{ name, url, expiresMinutes }` |
|
|
96
|
+
| `reset-password` | `{ name, url, expiresMinutes }` |
|
|
97
|
+
| `invite` | `{ inviterName, orgName, url, expiresHours }` |
|
|
98
|
+
| `mfa-enrolled` | `{ name, method, at }` |
|
|
99
|
+
| `security-alert` | `{ name, event, ip, at }` |
|
|
100
|
+
|
|
101
|
+
Translating them = shipping `mail.*` keys in an app catalog. Never edit a template.
|
|
102
|
+
|
|
103
|
+
## Errors
|
|
104
|
+
|
|
105
|
+
| Code | Fix |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `X_MAIL_LOCALE_MISSING` | `send(mail, data, { to, locale: ctx.locale })` |
|
|
108
|
+
| `X_MAIL_TEMPLATE_UNKNOWN` | export a `defineMail({ id })` and import it (also raised for an unregistered layout) |
|
|
109
|
+
| `X_MAIL_DUPLICATE` | rename one of two `defineMail({ id })` declarations |
|
|
110
|
+
| `X_MAIL_TEXT_MISSING` | add a text-bearing block to the template |
|
|
111
|
+
| `X_MAIL_DRIVER_UNAVAILABLE` | `setMailDriver(createMemoryDriver())` at boot |
|
|
112
|
+
| `X_MAIL_HEADER_INVALID` | strip CR/LF from the interpolated value before it reaches a header |
|
|
113
|
+
| `X_MAIL_SEND_FAILED` | the `cause` names the stage, the provider's status and whether a retry can help |
|
|
114
|
+
|
|
115
|
+
## Commands
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bun test packages/mail
|
|
119
|
+
bun run --filter @ultimat3/mail typecheck
|
|
120
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ultimat3/mail",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Transactional email as data: one template renders HTML and text, sent through a job.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
10
|
+
"directory": "packages/mail"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
30
|
+
"test": "bun test"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@ultimat3/core": "1.0.0",
|
|
34
|
+
"@ultimat3/i18n": "1.0.0",
|
|
35
|
+
"@ultimat3/jobs": "1.0.0",
|
|
36
|
+
"@ultimat3/schema": "1.0.0",
|
|
37
|
+
"@ultimat3/time": "1.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/base64.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Single responsibility: base64 over UTF-8 bytes. RFC 2047 encoded words and SMTP AUTH need the
|
|
2
|
+
// same operation, and `btoa` alone throws above code point 0xFF — so the string is encoded to
|
|
3
|
+
// bytes first and each byte re-packed as one Latin-1 char, which is what `btoa` actually takes.
|
|
4
|
+
|
|
5
|
+
export function base64Utf8(value: string): string {
|
|
6
|
+
let binary = '';
|
|
7
|
+
for (const byte of new TextEncoder().encode(value)) binary += String.fromCharCode(byte);
|
|
8
|
+
return btoa(binary);
|
|
9
|
+
}
|
package/src/blocks.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { TranslateVars, Translator } from '@ultimat3/i18n';
|
|
2
|
+
export type CalloutTone = 'info' | 'danger';
|
|
3
|
+
export type MailBlock = {
|
|
4
|
+
readonly kind: 'heading';
|
|
5
|
+
readonly key: string;
|
|
6
|
+
readonly vars?: TranslateVars | undefined;
|
|
7
|
+
} | {
|
|
8
|
+
readonly kind: 'paragraph';
|
|
9
|
+
readonly key: string;
|
|
10
|
+
readonly vars?: TranslateVars | undefined;
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: 'button';
|
|
13
|
+
readonly key: string;
|
|
14
|
+
readonly href: string;
|
|
15
|
+
readonly vars?: TranslateVars | undefined;
|
|
16
|
+
} | {
|
|
17
|
+
readonly kind: 'callout';
|
|
18
|
+
readonly key: string;
|
|
19
|
+
readonly tone: CalloutTone;
|
|
20
|
+
readonly vars?: TranslateVars | undefined;
|
|
21
|
+
}
|
|
22
|
+
/** A label key plus an already-localised value (a date, an IP, a method name). */
|
|
23
|
+
| {
|
|
24
|
+
readonly kind: 'detail';
|
|
25
|
+
readonly key: string;
|
|
26
|
+
readonly value: string;
|
|
27
|
+
} | {
|
|
28
|
+
readonly kind: 'divider';
|
|
29
|
+
};
|
|
30
|
+
/** Block constructors, so a template never hand-writes an object literal or a colour. */
|
|
31
|
+
export declare const blocks: Readonly<{
|
|
32
|
+
heading: (key: string, vars?: TranslateVars) => MailBlock;
|
|
33
|
+
paragraph: (key: string, vars?: TranslateVars) => MailBlock;
|
|
34
|
+
button: (key: string, href: string, vars?: TranslateVars) => MailBlock;
|
|
35
|
+
callout: (key: string, tone: CalloutTone, vars?: TranslateVars) => MailBlock;
|
|
36
|
+
detail: (key: string, value: string) => MailBlock;
|
|
37
|
+
divider: () => MailBlock;
|
|
38
|
+
}>;
|
|
39
|
+
export interface TemplateArgs<I> {
|
|
40
|
+
readonly data: I;
|
|
41
|
+
readonly t: Translator;
|
|
42
|
+
readonly locale: string;
|
|
43
|
+
/** IANA zone. Every date in a mail is formatted with it — never with the server's zone. */
|
|
44
|
+
readonly tz: string;
|
|
45
|
+
}
|
|
46
|
+
export type MailTemplate<I> = (args: TemplateArgs<I>) => readonly MailBlock[];
|
|
47
|
+
//# sourceMappingURL=blocks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blocks.d.ts","sourceRoot":"","sources":["blocks.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEhE,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE5C,MAAM,MAAM,SAAS,GACjB;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,GAAG,SAAS,CAAA;CAAE,GAC7F;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,GAAG,SAAS,CAAA;CAAE,GAC/F;IACE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;CAC3C,GACD;IACE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;CAC3C;AACH,kFAAkF;GAChF;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACzE;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAEjC,yFAAyF;AACzF,eAAO,MAAM,MAAM;mBACF,MAAM,SAAS,aAAa,KAAG,SAAS;qBACtC,MAAM,SAAS,aAAa,KAAG,SAAS;kBAC3C,MAAM,QAAQ,MAAM,SAAS,aAAa,KAAG,SAAS;mBAMrD,MAAM,QAAQ,WAAW,SAAS,aAAa,KAAG,SAAS;kBAM5D,MAAM,SAAS,MAAM,KAAG,SAAS;mBAClC,SAAS;EACtB,CAAC;AAEH,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IACjB,QAAQ,CAAC,CAAC,EAAE,UAAU,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,2FAA2F;IAC3F,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,MAAM,YAAY,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,SAAS,SAAS,EAAE,CAAC"}
|
package/src/blocks.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Single responsibility: the vocabulary a template writes in. A mail body is a list of blocks
|
|
2
|
+
// carrying i18n KEYS, never markup and never a colour — that is what makes one template able
|
|
3
|
+
// to produce an HTML part and a text part, and what keeps translation out of the renderer.
|
|
4
|
+
/** Block constructors, so a template never hand-writes an object literal or a colour. */
|
|
5
|
+
export const blocks = Object.freeze({
|
|
6
|
+
heading: (key, vars) => ({ kind: 'heading', key, vars }),
|
|
7
|
+
paragraph: (key, vars) => ({ kind: 'paragraph', key, vars }),
|
|
8
|
+
button: (key, href, vars) => ({
|
|
9
|
+
kind: 'button',
|
|
10
|
+
key,
|
|
11
|
+
href,
|
|
12
|
+
vars,
|
|
13
|
+
}),
|
|
14
|
+
callout: (key, tone, vars) => ({
|
|
15
|
+
kind: 'callout',
|
|
16
|
+
key,
|
|
17
|
+
tone,
|
|
18
|
+
vars,
|
|
19
|
+
}),
|
|
20
|
+
detail: (key, value) => ({ kind: 'detail', key, value }),
|
|
21
|
+
divider: () => ({ kind: 'divider' }),
|
|
22
|
+
});
|
|
23
|
+
//# sourceMappingURL=blocks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blocks.js","sourceRoot":"","sources":["blocks.ts"],"names":[],"mappings":"AAAA,8FAA8F;AAC9F,6FAA6F;AAC7F,2FAA2F;AAyB3F,yFAAyF;AACzF,MAAM,CAAC,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IAClC,OAAO,EAAE,CAAC,GAAW,EAAE,IAAoB,EAAa,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC;IAC3F,SAAS,EAAE,CAAC,GAAW,EAAE,IAAoB,EAAa,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC;IAC/F,MAAM,EAAE,CAAC,GAAW,EAAE,IAAY,EAAE,IAAoB,EAAa,EAAE,CAAC,CAAC;QACvE,IAAI,EAAE,QAAQ;QACd,GAAG;QACH,IAAI;QACJ,IAAI;KACL,CAAC;IACF,OAAO,EAAE,CAAC,GAAW,EAAE,IAAiB,EAAE,IAAoB,EAAa,EAAE,CAAC,CAAC;QAC7E,IAAI,EAAE,SAAS;QACf,GAAG;QACH,IAAI;QACJ,IAAI;KACL,CAAC;IACF,MAAM,EAAE,CAAC,GAAW,EAAE,KAAa,EAAa,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;IACnF,OAAO,EAAE,GAAc,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;CAChD,CAAC,CAAC"}
|
package/src/blocks.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Single responsibility: the vocabulary a template writes in. A mail body is a list of blocks
|
|
2
|
+
// carrying i18n KEYS, never markup and never a colour — that is what makes one template able
|
|
3
|
+
// to produce an HTML part and a text part, and what keeps translation out of the renderer.
|
|
4
|
+
|
|
5
|
+
import type { TranslateVars, Translator } from '@ultimat3/i18n';
|
|
6
|
+
|
|
7
|
+
export type CalloutTone = 'info' | 'danger';
|
|
8
|
+
|
|
9
|
+
export type MailBlock =
|
|
10
|
+
| { readonly kind: 'heading'; readonly key: string; readonly vars?: TranslateVars | undefined }
|
|
11
|
+
| { readonly kind: 'paragraph'; readonly key: string; readonly vars?: TranslateVars | undefined }
|
|
12
|
+
| {
|
|
13
|
+
readonly kind: 'button';
|
|
14
|
+
readonly key: string;
|
|
15
|
+
readonly href: string;
|
|
16
|
+
readonly vars?: TranslateVars | undefined;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: 'callout';
|
|
20
|
+
readonly key: string;
|
|
21
|
+
readonly tone: CalloutTone;
|
|
22
|
+
readonly vars?: TranslateVars | undefined;
|
|
23
|
+
}
|
|
24
|
+
/** A label key plus an already-localised value (a date, an IP, a method name). */
|
|
25
|
+
| { readonly kind: 'detail'; readonly key: string; readonly value: string }
|
|
26
|
+
| { readonly kind: 'divider' };
|
|
27
|
+
|
|
28
|
+
/** Block constructors, so a template never hand-writes an object literal or a colour. */
|
|
29
|
+
export const blocks = Object.freeze({
|
|
30
|
+
heading: (key: string, vars?: TranslateVars): MailBlock => ({ kind: 'heading', key, vars }),
|
|
31
|
+
paragraph: (key: string, vars?: TranslateVars): MailBlock => ({ kind: 'paragraph', key, vars }),
|
|
32
|
+
button: (key: string, href: string, vars?: TranslateVars): MailBlock => ({
|
|
33
|
+
kind: 'button',
|
|
34
|
+
key,
|
|
35
|
+
href,
|
|
36
|
+
vars,
|
|
37
|
+
}),
|
|
38
|
+
callout: (key: string, tone: CalloutTone, vars?: TranslateVars): MailBlock => ({
|
|
39
|
+
kind: 'callout',
|
|
40
|
+
key,
|
|
41
|
+
tone,
|
|
42
|
+
vars,
|
|
43
|
+
}),
|
|
44
|
+
detail: (key: string, value: string): MailBlock => ({ kind: 'detail', key, value }),
|
|
45
|
+
divider: (): MailBlock => ({ kind: 'divider' }),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export interface TemplateArgs<I> {
|
|
49
|
+
readonly data: I;
|
|
50
|
+
readonly t: Translator;
|
|
51
|
+
readonly locale: string;
|
|
52
|
+
/** IANA zone. Every date in a mail is formatted with it — never with the server's zone. */
|
|
53
|
+
readonly tz: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type MailTemplate<I> = (args: TemplateArgs<I>) => readonly MailBlock[];
|
package/src/catalog.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Catalog, type Locale, type NestedCatalog } from '@ultimat3/i18n';
|
|
2
|
+
export declare const MAIL_CATALOG_SOURCE: NestedCatalog;
|
|
3
|
+
export declare const MAIL_CATALOG: Catalog;
|
|
4
|
+
/** Called at boot next to `registerFrameworkCatalog()`. Idempotent. */
|
|
5
|
+
export declare function registerMailCatalog(locale?: Locale): void;
|
|
6
|
+
//# sourceMappingURL=catalog.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["catalog.ts"],"names":[],"mappings":"AAKA,OAAO,EACL,KAAK,OAAO,EAEZ,KAAK,MAAM,EAEX,KAAK,aAAa,EAEnB,MAAM,gBAAgB,CAAC;AAExB,eAAO,MAAM,mBAAmB,EAAE,aAkEjC,CAAC;AAEF,eAAO,MAAM,YAAY,EAAE,OAA0C,CAAC;AAEtE,uEAAuE;AACvE,wBAAgB,mBAAmB,CAAC,MAAM,GAAE,MAAuB,GAAG,IAAI,CAEzE"}
|
package/src/catalog.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Single responsibility: the English source strings for the keys this package's templates
|
|
2
|
+
// emit. Templates carry keys only; the words live here (and in an app's own catalogs, which
|
|
3
|
+
// are registered after this one and therefore win). Translating a framework mail means
|
|
4
|
+
// shipping `mail.*` in an app catalog — never editing a template.
|
|
5
|
+
import { DEFAULT_LOCALE, loadCatalog, registerCatalog, } from '@ultimat3/i18n';
|
|
6
|
+
export const MAIL_CATALOG_SOURCE = {
|
|
7
|
+
mail: {
|
|
8
|
+
footer: {
|
|
9
|
+
legal: 'You received this message because of activity on your account.',
|
|
10
|
+
help: 'Questions? Reply to this email and a human will answer.',
|
|
11
|
+
unsubscribe: 'Unsubscribe',
|
|
12
|
+
},
|
|
13
|
+
welcome: {
|
|
14
|
+
subject: 'Welcome to {appName}',
|
|
15
|
+
preheader: 'Your {appName} account is ready.',
|
|
16
|
+
heading: 'Welcome, {name}',
|
|
17
|
+
body: 'Your {appName} account is ready. Pick up where you left off any time.',
|
|
18
|
+
cta: 'Open {appName}',
|
|
19
|
+
},
|
|
20
|
+
'verify-email': {
|
|
21
|
+
subject: 'Verify your email address',
|
|
22
|
+
preheader: 'One click and this address is confirmed.',
|
|
23
|
+
heading: 'Verify your email, {name}',
|
|
24
|
+
body: 'Confirm this address so we can secure your account and reach you when it matters.',
|
|
25
|
+
cta: 'Verify email address',
|
|
26
|
+
expiry_one: 'This link expires in {count} minute.',
|
|
27
|
+
expiry_other: 'This link expires in {count} minutes.',
|
|
28
|
+
ignore: 'If you did not create this account, ignore this message.',
|
|
29
|
+
},
|
|
30
|
+
'reset-password': {
|
|
31
|
+
subject: 'Reset your password',
|
|
32
|
+
preheader: 'A link to choose a new password.',
|
|
33
|
+
heading: 'Reset your password, {name}',
|
|
34
|
+
body: 'Choose a new password. Existing sessions stay signed in until you sign them out.',
|
|
35
|
+
cta: 'Choose a new password',
|
|
36
|
+
expiry_one: 'This link expires in {count} minute.',
|
|
37
|
+
expiry_other: 'This link expires in {count} minutes.',
|
|
38
|
+
ignore: 'If you did not ask for this, no action is needed — your password is unchanged.',
|
|
39
|
+
},
|
|
40
|
+
invite: {
|
|
41
|
+
subject: '{inviterName} invited you to {orgName}',
|
|
42
|
+
preheader: 'Your invitation to {orgName}.',
|
|
43
|
+
heading: 'Join {orgName}',
|
|
44
|
+
body: '{inviterName} invited you to collaborate in {orgName}.',
|
|
45
|
+
cta: 'Accept invitation',
|
|
46
|
+
expiry_one: 'This invitation expires in {count} hour.',
|
|
47
|
+
expiry_other: 'This invitation expires in {count} hours.',
|
|
48
|
+
},
|
|
49
|
+
'mfa-enrolled': {
|
|
50
|
+
subject: 'Two-factor authentication is on',
|
|
51
|
+
preheader: 'A new second factor now protects your account.',
|
|
52
|
+
heading: 'Two-factor is on, {name}',
|
|
53
|
+
body: 'Every sign-in now needs a second factor as well as your password.',
|
|
54
|
+
'method-label': 'Method',
|
|
55
|
+
'method-totp': 'Authenticator app',
|
|
56
|
+
'method-webauthn': 'Passkey or security key',
|
|
57
|
+
'method-sms': 'Text message',
|
|
58
|
+
'at-label': 'Enabled',
|
|
59
|
+
help: 'If this was not you, remove the factor and change your password immediately.',
|
|
60
|
+
},
|
|
61
|
+
'security-alert': {
|
|
62
|
+
subject: 'Security alert on your account',
|
|
63
|
+
preheader: 'Confirm recent activity on your account.',
|
|
64
|
+
heading: 'Security alert, {name}',
|
|
65
|
+
alert: 'We noticed activity on your account that we want you to confirm.',
|
|
66
|
+
'event-label': 'Event',
|
|
67
|
+
'ip-label': 'IP address',
|
|
68
|
+
'at-label': 'When',
|
|
69
|
+
help: 'If this was not you, change your password and sign out every other session.',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
export const MAIL_CATALOG = loadCatalog(MAIL_CATALOG_SOURCE);
|
|
74
|
+
/** Called at boot next to `registerFrameworkCatalog()`. Idempotent. */
|
|
75
|
+
export function registerMailCatalog(locale = DEFAULT_LOCALE) {
|
|
76
|
+
registerCatalog(locale, MAIL_CATALOG);
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=catalog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog.js","sourceRoot":"","sources":["catalog.ts"],"names":[],"mappings":"AAAA,0FAA0F;AAC1F,4FAA4F;AAC5F,uFAAuF;AACvF,kEAAkE;AAElE,OAAO,EAEL,cAAc,EAEd,WAAW,EAEX,eAAe,GAChB,MAAM,gBAAgB,CAAC;AAExB,MAAM,CAAC,MAAM,mBAAmB,GAAkB;IAChD,IAAI,EAAE;QACJ,MAAM,EAAE;YACN,KAAK,EAAE,gEAAgE;YACvE,IAAI,EAAE,yDAAyD;YAC/D,WAAW,EAAE,aAAa;SAC3B;QACD,OAAO,EAAE;YACP,OAAO,EAAE,sBAAsB;YAC/B,SAAS,EAAE,kCAAkC;YAC7C,OAAO,EAAE,iBAAiB;YAC1B,IAAI,EAAE,uEAAuE;YAC7E,GAAG,EAAE,gBAAgB;SACtB;QACD,cAAc,EAAE;YACd,OAAO,EAAE,2BAA2B;YACpC,SAAS,EAAE,0CAA0C;YACrD,OAAO,EAAE,2BAA2B;YACpC,IAAI,EAAE,mFAAmF;YACzF,GAAG,EAAE,sBAAsB;YAC3B,UAAU,EAAE,sCAAsC;YAClD,YAAY,EAAE,uCAAuC;YACrD,MAAM,EAAE,0DAA0D;SACnE;QACD,gBAAgB,EAAE;YAChB,OAAO,EAAE,qBAAqB;YAC9B,SAAS,EAAE,kCAAkC;YAC7C,OAAO,EAAE,6BAA6B;YACtC,IAAI,EAAE,kFAAkF;YACxF,GAAG,EAAE,uBAAuB;YAC5B,UAAU,EAAE,sCAAsC;YAClD,YAAY,EAAE,uCAAuC;YACrD,MAAM,EAAE,gFAAgF;SACzF;QACD,MAAM,EAAE;YACN,OAAO,EAAE,wCAAwC;YACjD,SAAS,EAAE,+BAA+B;YAC1C,OAAO,EAAE,gBAAgB;YACzB,IAAI,EAAE,wDAAwD;YAC9D,GAAG,EAAE,mBAAmB;YACxB,UAAU,EAAE,0CAA0C;YACtD,YAAY,EAAE,2CAA2C;SAC1D;QACD,cAAc,EAAE;YACd,OAAO,EAAE,iCAAiC;YAC1C,SAAS,EAAE,gDAAgD;YAC3D,OAAO,EAAE,0BAA0B;YACnC,IAAI,EAAE,mEAAmE;YACzE,cAAc,EAAE,QAAQ;YACxB,aAAa,EAAE,mBAAmB;YAClC,iBAAiB,EAAE,yBAAyB;YAC5C,YAAY,EAAE,cAAc;YAC5B,UAAU,EAAE,SAAS;YACrB,IAAI,EAAE,8EAA8E;SACrF;QACD,gBAAgB,EAAE;YAChB,OAAO,EAAE,gCAAgC;YACzC,SAAS,EAAE,0CAA0C;YACrD,OAAO,EAAE,wBAAwB;YACjC,KAAK,EAAE,kEAAkE;YACzE,aAAa,EAAE,OAAO;YACtB,UAAU,EAAE,YAAY;YACxB,UAAU,EAAE,MAAM;YAClB,IAAI,EAAE,6EAA6E;SACpF;KACF;CACF,CAAC;AAEF,MAAM,CAAC,MAAM,YAAY,GAAY,WAAW,CAAC,mBAAmB,CAAC,CAAC;AAEtE,uEAAuE;AACvE,MAAM,UAAU,mBAAmB,CAAC,MAAM,GAAW,cAAc;IACjE,eAAe,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;AACxC,CAAC"}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Single responsibility: the English source strings for the keys this package's templates
|
|
2
|
+
// emit. Templates carry keys only; the words live here (and in an app's own catalogs, which
|
|
3
|
+
// are registered after this one and therefore win). Translating a framework mail means
|
|
4
|
+
// shipping `mail.*` in an app catalog — never editing a template.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type Catalog,
|
|
8
|
+
DEFAULT_LOCALE,
|
|
9
|
+
type Locale,
|
|
10
|
+
loadCatalog,
|
|
11
|
+
type NestedCatalog,
|
|
12
|
+
registerCatalog,
|
|
13
|
+
} from '@ultimat3/i18n';
|
|
14
|
+
|
|
15
|
+
export const MAIL_CATALOG_SOURCE: NestedCatalog = {
|
|
16
|
+
mail: {
|
|
17
|
+
footer: {
|
|
18
|
+
legal: 'You received this message because of activity on your account.',
|
|
19
|
+
help: 'Questions? Reply to this email and a human will answer.',
|
|
20
|
+
unsubscribe: 'Unsubscribe',
|
|
21
|
+
},
|
|
22
|
+
welcome: {
|
|
23
|
+
subject: 'Welcome to {appName}',
|
|
24
|
+
preheader: 'Your {appName} account is ready.',
|
|
25
|
+
heading: 'Welcome, {name}',
|
|
26
|
+
body: 'Your {appName} account is ready. Pick up where you left off any time.',
|
|
27
|
+
cta: 'Open {appName}',
|
|
28
|
+
},
|
|
29
|
+
'verify-email': {
|
|
30
|
+
subject: 'Verify your email address',
|
|
31
|
+
preheader: 'One click and this address is confirmed.',
|
|
32
|
+
heading: 'Verify your email, {name}',
|
|
33
|
+
body: 'Confirm this address so we can secure your account and reach you when it matters.',
|
|
34
|
+
cta: 'Verify email address',
|
|
35
|
+
expiry_one: 'This link expires in {count} minute.',
|
|
36
|
+
expiry_other: 'This link expires in {count} minutes.',
|
|
37
|
+
ignore: 'If you did not create this account, ignore this message.',
|
|
38
|
+
},
|
|
39
|
+
'reset-password': {
|
|
40
|
+
subject: 'Reset your password',
|
|
41
|
+
preheader: 'A link to choose a new password.',
|
|
42
|
+
heading: 'Reset your password, {name}',
|
|
43
|
+
body: 'Choose a new password. Existing sessions stay signed in until you sign them out.',
|
|
44
|
+
cta: 'Choose a new password',
|
|
45
|
+
expiry_one: 'This link expires in {count} minute.',
|
|
46
|
+
expiry_other: 'This link expires in {count} minutes.',
|
|
47
|
+
ignore: 'If you did not ask for this, no action is needed — your password is unchanged.',
|
|
48
|
+
},
|
|
49
|
+
invite: {
|
|
50
|
+
subject: '{inviterName} invited you to {orgName}',
|
|
51
|
+
preheader: 'Your invitation to {orgName}.',
|
|
52
|
+
heading: 'Join {orgName}',
|
|
53
|
+
body: '{inviterName} invited you to collaborate in {orgName}.',
|
|
54
|
+
cta: 'Accept invitation',
|
|
55
|
+
expiry_one: 'This invitation expires in {count} hour.',
|
|
56
|
+
expiry_other: 'This invitation expires in {count} hours.',
|
|
57
|
+
},
|
|
58
|
+
'mfa-enrolled': {
|
|
59
|
+
subject: 'Two-factor authentication is on',
|
|
60
|
+
preheader: 'A new second factor now protects your account.',
|
|
61
|
+
heading: 'Two-factor is on, {name}',
|
|
62
|
+
body: 'Every sign-in now needs a second factor as well as your password.',
|
|
63
|
+
'method-label': 'Method',
|
|
64
|
+
'method-totp': 'Authenticator app',
|
|
65
|
+
'method-webauthn': 'Passkey or security key',
|
|
66
|
+
'method-sms': 'Text message',
|
|
67
|
+
'at-label': 'Enabled',
|
|
68
|
+
help: 'If this was not you, remove the factor and change your password immediately.',
|
|
69
|
+
},
|
|
70
|
+
'security-alert': {
|
|
71
|
+
subject: 'Security alert on your account',
|
|
72
|
+
preheader: 'Confirm recent activity on your account.',
|
|
73
|
+
heading: 'Security alert, {name}',
|
|
74
|
+
alert: 'We noticed activity on your account that we want you to confirm.',
|
|
75
|
+
'event-label': 'Event',
|
|
76
|
+
'ip-label': 'IP address',
|
|
77
|
+
'at-label': 'When',
|
|
78
|
+
help: 'If this was not you, change your password and sign out every other session.',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const MAIL_CATALOG: Catalog = loadCatalog(MAIL_CATALOG_SOURCE);
|
|
84
|
+
|
|
85
|
+
/** Called at boot next to `registerFrameworkCatalog()`. Idempotent. */
|
|
86
|
+
export function registerMailCatalog(locale: Locale = DEFAULT_LOCALE): void {
|
|
87
|
+
registerCatalog(locale, MAIL_CATALOG);
|
|
88
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Single responsibility: environment → transport. The one place that decides which `MailDriver`
|
|
2
|
+
// a boot installs, so `x dev`, a worker container and any custom host all resolve it identically.
|
|
3
|
+
// The two production transports are useless until something constructs them from a credential;
|
|
4
|
+
// this is that something, and it is keyed on env rather than a config field so the same image
|
|
5
|
+
// deploys to every environment.
|
|
6
|
+
|
|
7
|
+
import { ConfigInvalidError } from '@ultimat3/core';
|
|
8
|
+
import { createMemoryDriver, type MailDriver } from './driver';
|
|
9
|
+
import { createResendDriver } from './driver-resend';
|
|
10
|
+
import { createSmtpDriver } from './driver-smtp';
|
|
11
|
+
|
|
12
|
+
/** The keys read here, and nothing else. Named once so docs and tests cannot drift from the code. */
|
|
13
|
+
export const MAIL_ENV_KEYS = ['SMTP_URL', 'RESEND_API_KEY', 'MAIL_FROM', 'MAIL_POOL_SIZE'] as const;
|
|
14
|
+
|
|
15
|
+
export type MailEnvironment = Readonly<Record<string, string | undefined>>;
|
|
16
|
+
|
|
17
|
+
export interface MailSelection {
|
|
18
|
+
readonly driver: MailDriver;
|
|
19
|
+
/**
|
|
20
|
+
* Why this driver, in one line: the env key that selected it, or what to set to change it.
|
|
21
|
+
* A boot prints it, so "which transport is this process using" is never a guess.
|
|
22
|
+
*/
|
|
23
|
+
readonly detail: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const nonEmpty = (value: string | undefined): string | undefined =>
|
|
27
|
+
value === undefined || value.trim().length === 0 ? undefined : value.trim();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Both transports put the address in the envelope and in `From:`, so neither can be built
|
|
31
|
+
* without it. Refused here rather than inside the driver: the cause names the env key that is
|
|
32
|
+
* missing, which is the thing an operator can actually set.
|
|
33
|
+
*/
|
|
34
|
+
function requireFrom(env: MailEnvironment, selectedBy: string): string {
|
|
35
|
+
const from = nonEmpty(env['MAIL_FROM']);
|
|
36
|
+
if (from === undefined) {
|
|
37
|
+
throw new ConfigInvalidError({
|
|
38
|
+
cause: `${selectedBy} selects a mail transport, but MAIL_FROM is unset — no envelope sender`,
|
|
39
|
+
fix: 'set MAIL_FROM="App <no-reply@yourdomain.test>" in .env.production',
|
|
40
|
+
meta: { selectedBy, missing: 'MAIL_FROM' },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return from;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `Number('abc')` is `NaN`, and `NaN` reaches the driver as "poolSize: NaN" — an accurate but
|
|
48
|
+
* useless cause, because the operator set a string in a file and the driver never saw the key.
|
|
49
|
+
* Parsed at the boundary so the error names `MAIL_POOL_SIZE` instead.
|
|
50
|
+
*/
|
|
51
|
+
function poolSizeFrom(env: MailEnvironment): number | undefined {
|
|
52
|
+
const raw = nonEmpty(env['MAIL_POOL_SIZE']);
|
|
53
|
+
if (raw === undefined) return undefined;
|
|
54
|
+
const parsed = Number(raw);
|
|
55
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
56
|
+
throw new ConfigInvalidError({
|
|
57
|
+
cause: `MAIL_POOL_SIZE is "${raw}", which is not a whole number of connections >= 1`,
|
|
58
|
+
fix: 'set MAIL_POOL_SIZE=4 in .env.production, or unset it to keep the default',
|
|
59
|
+
meta: { MAIL_POOL_SIZE: raw },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A credential selects its transport; no credential catches mail in memory. Two credentials is
|
|
67
|
+
* the one case that cannot be answered by picking a winner — whichever this chose would be the
|
|
68
|
+
* one an operator did not mean half the time, and mail would silently leave by the wrong path.
|
|
69
|
+
*/
|
|
70
|
+
export function selectMailDriver(env: MailEnvironment): MailSelection {
|
|
71
|
+
const smtpUrl = nonEmpty(env['SMTP_URL']);
|
|
72
|
+
const resendKey = nonEmpty(env['RESEND_API_KEY']);
|
|
73
|
+
|
|
74
|
+
if (smtpUrl !== undefined && resendKey !== undefined) {
|
|
75
|
+
throw new ConfigInvalidError({
|
|
76
|
+
cause: 'SMTP_URL and RESEND_API_KEY are both set — two transports claim the same mail',
|
|
77
|
+
fix: 'unset one of them in .env.production: a process delivers through exactly one transport',
|
|
78
|
+
meta: { selected: ['SMTP_URL', 'RESEND_API_KEY'] },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (smtpUrl !== undefined) {
|
|
83
|
+
const poolSize = poolSizeFrom(env);
|
|
84
|
+
return {
|
|
85
|
+
driver: createSmtpDriver({
|
|
86
|
+
url: smtpUrl,
|
|
87
|
+
from: requireFrom(env, 'SMTP_URL'),
|
|
88
|
+
...(poolSize === undefined ? {} : { poolSize }),
|
|
89
|
+
}),
|
|
90
|
+
detail: 'SMTP_URL',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (resendKey !== undefined) {
|
|
95
|
+
return {
|
|
96
|
+
driver: createResendDriver({ apiKey: resendKey, from: requireFrom(env, 'RESEND_API_KEY') }),
|
|
97
|
+
detail: 'RESEND_API_KEY',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
driver: createMemoryDriver(),
|
|
103
|
+
detail: 'caught in memory — set SMTP_URL or RESEND_API_KEY to deliver',
|
|
104
|
+
};
|
|
105
|
+
}
|