@ultimat3/mail 1.2.0 → 3.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/CLAUDE.md +107 -0
- package/README.md +22 -7
- package/package.json +7 -6
- package/src/driver-env.ts +28 -8
- package/src/driver-smtp.ts +10 -4
- package/src/driver.ts +24 -2
- package/src/envelope-address.ts +29 -0
- package/src/errors.ts +60 -3
- package/src/header-safety.ts +25 -0
- package/src/idempotency.ts +25 -4
- package/src/index.ts +15 -2
- package/src/job.ts +14 -1
- package/src/mail.ts +10 -3
- package/src/smtp-client.ts +9 -0
- package/src/templates/index.ts +4 -2
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# @ultimat3/mail — agent notes
|
|
2
|
+
|
|
3
|
+
**Tier 4** (`scripts/lib/tiers.ts`). May import `core`, `schema`, `i18n`, `time`, `money`, `jobs`; never `auth`, `http`, `ui`, `render`. **Zero external deps** — no nodemailer, no MJML, no CSS library.
|
|
4
|
+
|
|
5
|
+
## Boundary
|
|
6
|
+
|
|
7
|
+
| File | Single responsibility |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `mail.ts` | `defineMail`, the registry, `send` / `sendById` / `renderMessage` |
|
|
10
|
+
| `templates/` | the six framework mails, as data |
|
|
11
|
+
| `blocks.ts` | the template vocabulary: `MailBlock`, `blocks`, `MailTemplate`, `TemplateArgs` |
|
|
12
|
+
| `render.ts` | blocks → HTML **and** text, plus the layout call and the footer slots |
|
|
13
|
+
| `layout.ts` | `MAIL_TOKENS` (light + dark), the 600px table shell, layout registry |
|
|
14
|
+
| `driver.ts` | `MailDriver` + memory/log/unconfigured + `resultFor` + the `setMailDriver` seam |
|
|
15
|
+
| `driver-env.ts` | `selectMailDriver`: which transport an environment installs, and nothing else |
|
|
16
|
+
| `header-safety.ts` | `assertHeaderSafe`: the CR/LF gate on a `MailMessage`, so every driver refuses the same one |
|
|
17
|
+
| `driver-smtp.ts` | `createSmtpDriver`: `SMTP_URL` parsing, the pool ceiling, one send |
|
|
18
|
+
| `driver-resend.ts` | `createResendDriver`: one `POST /emails`, status → retryable |
|
|
19
|
+
| `smtp-client.ts` | the conversation: greeting → EHLO → STARTTLS → AUTH → envelope → DATA |
|
|
20
|
+
| `smtp-protocol.ts` | pure protocol: reply framing, capabilities, AUTH payloads, dot-stuffing |
|
|
21
|
+
| `smtp-socket.ts` | the one production `SmtpStream`, over `Bun.connect` |
|
|
22
|
+
| `mime.ts` | `MailMessage` → RFC 5322: header order, RFC 2047, folding, quoted-printable |
|
|
23
|
+
| `base64.ts` | base64 over UTF-8 bytes, shared by RFC 2047 and SMTP AUTH |
|
|
24
|
+
| `job.ts` | `sendMailJob` and the envelope schema |
|
|
25
|
+
| `idempotency.ts` | `mailIdempotencyKey` — apart from `job.ts` because the transports need it too |
|
|
26
|
+
| `catalog.ts` | English source strings for `mail.*`. Data, not code |
|
|
27
|
+
| `html.ts` | escaping + `safeUrl`. The only place that builds an attribute |
|
|
28
|
+
|
|
29
|
+
## Rules
|
|
30
|
+
|
|
31
|
+
- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a `defineMail` file
|
|
32
|
+
imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on
|
|
33
|
+
every access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
|
|
34
|
+
- `SendOptions.locale` is non-optional in the type. Never relax it, never default it.
|
|
35
|
+
- Text part is derived from blocks and must be non-empty (`X_MAIL_TEXT_MISSING`).
|
|
36
|
+
- No literal user-facing string in `templates/` or `layout.ts` — keys only.
|
|
37
|
+
- No hex outside `MAIL_TOKENS`. Base styling is inlined (clients strip `<style>`); dark mode is
|
|
38
|
+
one `prefers-color-scheme` block keyed on short `data-x` role codes.
|
|
39
|
+
- Never format a date without `options.tz`. The `Date:` header is UTC, stated as `+0000`.
|
|
40
|
+
- New block kind: `MailBlock` + `blocks` + `htmlOf` + `textOf`, same commit.
|
|
41
|
+
- A transport failure is `sendFailed({ stage, status, retryable, fix })` — never a bare throw, and
|
|
42
|
+
never a `retryable` guess. `stage` is the `SendStage` union in `errors.ts`; a new step goes there
|
|
43
|
+
first. The transient set is 4xx over SMTP, and 408/409/425/429 + 5xx over HTTP — that HTTP set
|
|
44
|
+
lives in `RETRYABLE_STATUSES` (`driver-resend.ts`) and is edited there, never restated.
|
|
45
|
+
- A transport is selected from the environment by `selectMailDriver`, never from an `app.config.ts`
|
|
46
|
+
field — nothing loads that file's contents at runtime, so a `mail:` config block would be a
|
|
47
|
+
setting no boot could read. Two credentials at once is refused, not resolved: mail leaving by
|
|
48
|
+
the wrong provider is not a failure anyone sees. The credential never reaches a printed string.
|
|
49
|
+
- **No credential is answered by the ENVIRONMENT, and outside development it REFUSES** (`As of
|
|
50
|
+
2026-08`). It answered the memory driver everywhere, including production — so a deploy that
|
|
51
|
+
configured no transport reported `accepted` for mail that never left the process, with no error
|
|
52
|
+
anywhere. `createUnconfiguredDriver` rejects every send with `X_MAIL_CREDENTIAL_MISSING` instead.
|
|
53
|
+
Three parts of that are decisions, not details. It is a **driver and not a boot refusal**, so an
|
|
54
|
+
app that sends no mail still deploys and one that does fails on the path that needed the
|
|
55
|
+
capability. `staging` refuses too, because staging exists to fail the way production fails —
|
|
56
|
+
which is `isLocal()`'s own rule, read from **core** rather than restated here, so mail and storage
|
|
57
|
+
cannot disagree about which deploy this is. And it is its **own code**: `X_MAIL_DRIVER_UNAVAILABLE`
|
|
58
|
+
is a developer who never called `setMailDriver`, this is an operator who set no variable, and one
|
|
59
|
+
code for two audiences is a `fix:` that is wrong half the time.
|
|
60
|
+
- **The CR/LF header rule is a property of the MESSAGE, checked at `renderMessage` and again in
|
|
61
|
+
`sendMailJob`** (`As of 2026-08`). `mime.ts` held the only copy, so a subject an SMTP deploy
|
|
62
|
+
refused was accepted by memory in dev and by Resend in staging — the same app, three answers.
|
|
63
|
+
`mime.ts` keeps its gate and is not redundant: it also covers `From`, `Date` and `Message-ID`,
|
|
64
|
+
which the transport mints and no message-level check can see. The job's copy exists because a
|
|
65
|
+
queue row is not necessarily one this process rendered, and `mailMessageSchema` proves a payload's
|
|
66
|
+
SHAPE only. A driver reached DIRECTLY through `mailDriver().send(handBuilt)` is off that path —
|
|
67
|
+
`assertHeaderSafe` is exported for a custom driver that wants the same gate.
|
|
68
|
+
- **The SMTP `Message-ID` is content-derived, so every attempt of one send presents the same one**
|
|
69
|
+
(`As of 2026-08`). It was a fresh `nanoid` per attempt, and a send that times out after `DATA` is
|
|
70
|
+
classified retryable — so the retry was a second email to every mailbox that would otherwise have
|
|
71
|
+
collapsed it, while `SendResult.idempotencyKey` claimed one message on both transports. This
|
|
72
|
+
narrows the gap and cannot close it: SMTP has no idempotency protocol, so the header is an
|
|
73
|
+
opportunity for the receiving side and never a guarantee, where Resend's `Idempotency-Key` is
|
|
74
|
+
enforced by the provider. That asymmetry is a transport DIFFERENCE and is pinned as one.
|
|
75
|
+
`mailMessageIdToken` is a one-way digest of `mailIdempotencyKey`, never the key itself: the key
|
|
76
|
+
holds the recipient list, bcc included, and a `Message-ID` is visible to all of them.
|
|
77
|
+
- **`driver-parity.test.ts` asserts every driver's behaviour in ONE test.** Both production drivers
|
|
78
|
+
run for real — SMTP over a fake `SmtpStream`, Resend over an injected `fetch` — so a case compares
|
|
79
|
+
what each did rather than a proxy for it, and neither side can move alone. The memory driver joins
|
|
80
|
+
the cases about the MESSAGE and none about a wire: it dials nothing, maps no status and carries no
|
|
81
|
+
idempotency header, and that is documented difference, not defect.
|
|
82
|
+
- `Bcc` is an envelope field. It reaches `RCPT TO` and Resend's body, never a header.
|
|
83
|
+
- Recipient addresses stay out of logs and out of error text we write ourselves; the server's own
|
|
84
|
+
reply is passed through verbatim, and that is where the refused address comes from.
|
|
85
|
+
- Every header value is checked for CR/LF (`X_MAIL_HEADER_INVALID`) before folding — interpolated
|
|
86
|
+
data reaches `Subject`, and a break there injects headers. Refuse it, never strip it.
|
|
87
|
+
- Every ENVELOPE address is checked the same way and separately (`X_MAIL_ADDRESS_INVALID`,
|
|
88
|
+
`envelope-address.ts`, called by `smtpDeliver`). Two wire formats, one gate each: `bcc` is not a
|
|
89
|
+
header, so the header gate never sees it, and on the inline send path no schema does either — a
|
|
90
|
+
`bcc` of `ops@x.test\r\nRCPT TO:<evil@y.test>` relayed mail over the app's own authenticated
|
|
91
|
+
connection. The refused set is control characters plus `<` and `>`; a space is deliberately
|
|
92
|
+
allowed (quoted local-parts) and non-ASCII is SMTPUTF8's question, not this check's.
|
|
93
|
+
|
|
94
|
+
## Commands
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
bun test packages/mail
|
|
98
|
+
bun run --filter @ultimat3/mail typecheck
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Gotchas:
|
|
102
|
+
- The registry erases `I` (`AnyMailDefinition`); `sendById` holds the package's only cast.
|
|
103
|
+
- No job driver configured ⇒ `send` delivers inline rather than dropping the message.
|
|
104
|
+
- Tests must not call `resetMails()` — template registration is module-level and shared.
|
|
105
|
+
- `driver-smtp.test.ts` runs a real `Bun.listen` SMTP server on loopback. The sealed test network
|
|
106
|
+
covers `fetch` only, so this is allowed — and it is the only proof the socket, the chunk queue
|
|
107
|
+
and the reply framing agree. Resend tests inject `options.fetch`; never unseal.
|
package/README.md
CHANGED
|
@@ -33,7 +33,8 @@ delivers inline only when `{ sync: true }` is passed or no job driver is configu
|
|
|
33
33
|
| Every string is a key | `mail.<id>.<slot>`; English lives in `src/catalog.ts` and app catalogs override it |
|
|
34
34
|
| Every colour is a token | `MAIL_TOKENS` in `layout.ts` holds light + dark hexes; templates never see a hex |
|
|
35
35
|
| Every date takes an IANA zone | `options.tz`, else `ctx.tz`, else `UTC` |
|
|
36
|
-
|
|
|
36
|
+
| No CR/LF in a header-bound field | checked in `renderMessage` and again in `sendMailJob`, so every driver refuses the same message (`X_MAIL_HEADER_INVALID`). `mime.ts` keeps its own gate for the headers the SMTP transport mints itself |
|
|
37
|
+
| Sending is a job | `retry: { attempts: 5, backoff: 'exponential' }`, idempotency key derived from `(mailId, recipients, hash(rendered))`, or `(mailId, your key)` when you pass one — a caller's key is scoped to its mail so two templates cannot dedupe each other away |
|
|
37
38
|
|
|
38
39
|
## Drivers
|
|
39
40
|
|
|
@@ -44,21 +45,29 @@ delivers inline only when `{ sync: true }` is passed or no job driver is configu
|
|
|
44
45
|
|---|---|---|
|
|
45
46
|
| `createMemoryDriver()` | dev, tests | retains messages; `outbox()` / `lastTo()` feed the `/_x` mail panel |
|
|
46
47
|
| `createLogDriver()` | workers without credentials | one structured line per message through core's `logger`; bodies never logged |
|
|
48
|
+
| `createUnconfiguredDriver(env)` | a deploy that configured no transport | refuses every send with `X_MAIL_CREDENTIAL_MISSING`; delivers nothing and claims nothing |
|
|
47
49
|
| `createSmtpDriver({ url, from })` | prod | real ESMTP over `Bun.connect`: STARTTLS, `AUTH PLAIN`/`LOGIN`, quoted-printable MIME |
|
|
48
50
|
| `createResendDriver({ apiKey, from })` | prod | one `POST /emails`, `Idempotency-Key` on every request |
|
|
49
51
|
|
|
50
52
|
### Which one a boot installs
|
|
51
53
|
|
|
52
|
-
`selectMailDriver(env)` is the one answer, and `x dev`
|
|
53
|
-
embedded default, the same law the database, event bus and storage bindings follow. Nothing about
|
|
54
|
+
`selectMailDriver(env)` is the one answer, and `x dev` and `runRole` both call it. Nothing about
|
|
54
55
|
the app changes between environments; the credential does.
|
|
55
56
|
|
|
56
57
|
| env | driver |
|
|
57
58
|
|---|---|
|
|
58
|
-
| *(nothing set)
|
|
59
|
+
| *(nothing set)*, `development` / `test` | `createMemoryDriver()` — caught, never sent |
|
|
60
|
+
| *(nothing set)*, `staging` / `production` | `createUnconfiguredDriver(...)` — every send is `X_MAIL_CREDENTIAL_MISSING` |
|
|
59
61
|
| `SMTP_URL` + `MAIL_FROM` | `createSmtpDriver(...)`, `MAIL_POOL_SIZE` optional |
|
|
60
62
|
| `RESEND_API_KEY` + `MAIL_FROM` | `createResendDriver(...)` |
|
|
61
63
|
|
|
64
|
+
**No credential outside development is a refusal, not the embedded default.** The memory driver
|
|
65
|
+
there answered `accepted` for mail that never left the process — password resets, receipts and
|
|
66
|
+
invitations all reported as sent, none delivered, no error anywhere. The refusal lands on the
|
|
67
|
+
**send**, not on the boot, so an app that sends no mail still deploys. Which environment this is
|
|
68
|
+
comes from core's `isLocal()` (`ULTIMATE_ENV`, else `NODE_ENV`, else `development`) — mail does not
|
|
69
|
+
own a second reading of it.
|
|
70
|
+
|
|
62
71
|
Both credentials at once is `X_CONFIG_INVALID` rather than a winner picked for you, and a
|
|
63
72
|
transport with no `MAIL_FROM` is refused at boot instead of on the first send. A host that is not
|
|
64
73
|
`x dev` calls `selectMailDriver` itself, or constructs a driver directly — `setMailDriver` is the
|
|
@@ -76,7 +85,9 @@ with STARTTLS. Credentials are percent-decoded, so a password with `@` or `/` wo
|
|
|
76
85
|
| Any rejected recipient fails the send | delivering to three of four addresses and reporting success is the one outcome a caller cannot detect |
|
|
77
86
|
| `poolSize` (default 4) caps concurrent connections | a burst of sends queues instead of opening one socket each |
|
|
78
87
|
| `Bcc` never reaches a header | it travels in `RCPT TO` only |
|
|
79
|
-
|
|
|
88
|
+
| Every envelope address is gated for CR/LF | `MAIL FROM`/`RCPT TO` are built by interpolation, and `bcc` is the one address no header check ever sees. Refused (`X_MAIL_ADDRESS_INVALID`), never stripped — a rewritten address delivers somewhere else |
|
|
89
|
+
| The reported `id` is the `Message-ID` | an SMTP `250` carries nothing a caller could correlate |
|
|
90
|
+
| The `Message-ID` is stable per message | SMTP has no idempotency protocol, so it is the one identifier a receiving mailbox can collapse a retry on. A fresh token per attempt made a timeout past `DATA` a second email. It is a one-way **digest** of `mailIdempotencyKey`, never the key: the key holds the recipient list, and this header is visible to all of them |
|
|
80
91
|
|
|
81
92
|
### Resend
|
|
82
93
|
|
|
@@ -87,7 +98,9 @@ is a configuration problem that retrying cannot fix.
|
|
|
87
98
|
|
|
88
99
|
## Framework mails
|
|
89
100
|
|
|
90
|
-
Registered by importing them. `FRAMEWORK_MAILS` is the list
|
|
101
|
+
Registered by importing them — the import IS the registration. `FRAMEWORK_MAILS` is the list, and
|
|
102
|
+
`registeredMails()` / `registeredMailIds()` answer for an app's own mails as well. There is no
|
|
103
|
+
`x mail` command; a host that wants to list or preview a mail calls those and `renderMessage()`.
|
|
91
104
|
|
|
92
105
|
| id | Input |
|
|
93
106
|
|---|---|
|
|
@@ -108,8 +121,10 @@ Translating them = shipping `mail.*` keys in an app catalog. Never edit a templa
|
|
|
108
121
|
| `X_MAIL_TEMPLATE_UNKNOWN` | export a `defineMail({ id })` and import it (also raised for an unregistered layout) |
|
|
109
122
|
| `X_MAIL_DUPLICATE` | rename one of two `defineMail({ id })` declarations |
|
|
110
123
|
| `X_MAIL_TEXT_MISSING` | add a text-bearing block to the template |
|
|
111
|
-
| `X_MAIL_DRIVER_UNAVAILABLE` | `setMailDriver(createMemoryDriver())` at boot |
|
|
124
|
+
| `X_MAIL_DRIVER_UNAVAILABLE` | `setMailDriver(createMemoryDriver())` at boot — a wiring bug |
|
|
125
|
+
| `X_MAIL_CREDENTIAL_MISSING` | set `SMTP_URL` (or `RESEND_API_KEY`) and `MAIL_FROM` in the deployment — an operations one |
|
|
112
126
|
| `X_MAIL_HEADER_INVALID` | strip CR/LF from the interpolated value before it reaches a header |
|
|
127
|
+
| `X_MAIL_ADDRESS_INVALID` | pass a bare `addr-spec` — an envelope address may hold no control character and no `<`/`>` |
|
|
113
128
|
| `X_MAIL_SEND_FAILED` | the `cause` names the stage, the provider's status and whether a retry can help |
|
|
114
129
|
|
|
115
130
|
## Commands
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mail",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Transactional email as data: one template renders HTML and text, sent through a job.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,10 +31,10 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
-
"@ultimat3/i18n": "
|
|
35
|
-
"@ultimat3/jobs": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "3.0.0",
|
|
35
|
+
"@ultimat3/i18n": "3.0.0",
|
|
36
|
+
"@ultimat3/jobs": "3.0.0",
|
|
37
|
+
"@ultimat3/schema": "3.0.0",
|
|
38
|
+
"@ultimat3/time": "3.0.0"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/driver-env.ts
CHANGED
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
// this is that something, and it is keyed on env rather than a config field so the same image
|
|
5
5
|
// deploys to every environment.
|
|
6
6
|
|
|
7
|
-
import { ConfigInvalidError } from '@ultimat3/core';
|
|
8
|
-
import { createMemoryDriver, type MailDriver } from './driver';
|
|
7
|
+
import { ConfigInvalidError, isLocal, resolveEnvironment } from '@ultimat3/core';
|
|
8
|
+
import { createMemoryDriver, createUnconfiguredDriver, type MailDriver } from './driver';
|
|
9
9
|
import { createResendDriver } from './driver-resend';
|
|
10
10
|
import { createSmtpDriver } from './driver-smtp';
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* The MAIL keys read here, and nothing else. Named once so docs and tests cannot drift from the
|
|
14
|
+
* code. `ULTIMATE_ENV`/`NODE_ENV` are deliberately absent: which deploy this is belongs to core's
|
|
15
|
+
* one resolver, and restating it as a mail key would make it two settings with one meaning.
|
|
16
|
+
*/
|
|
13
17
|
export const MAIL_ENV_KEYS = ['SMTP_URL', 'RESEND_API_KEY', 'MAIL_FROM', 'MAIL_POOL_SIZE'] as const;
|
|
14
18
|
|
|
15
19
|
export type MailEnvironment = Readonly<Record<string, string | undefined>>;
|
|
@@ -63,9 +67,17 @@ function poolSizeFrom(env: MailEnvironment): number | undefined {
|
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
/**
|
|
66
|
-
* A credential selects its transport
|
|
67
|
-
*
|
|
68
|
-
*
|
|
70
|
+
* A credential selects its transport. Two credentials is the one case that cannot be answered by
|
|
71
|
+
* picking a winner — whichever this chose would be the one an operator did not mean half the time,
|
|
72
|
+
* and mail would silently leave by the wrong path.
|
|
73
|
+
*
|
|
74
|
+
* NO credential is answered by the ENVIRONMENT, and it is the one decision here that is not about
|
|
75
|
+
* a credential. In development and test it is the memory driver: the `/_x` panel shows what a
|
|
76
|
+
* template renders in every locale and nothing escapes to a real address. Anywhere else it is a
|
|
77
|
+
* driver that refuses, because the memory driver in production reports `accepted` for mail that
|
|
78
|
+
* never left the process — every password reset, receipt and invitation "sent", none delivered,
|
|
79
|
+
* and no error anywhere to find it by. `isLocal` is core's one reader of that question, so mail
|
|
80
|
+
* cannot disagree with storage about which deploy this is.
|
|
69
81
|
*/
|
|
70
82
|
export function selectMailDriver(env: MailEnvironment): MailSelection {
|
|
71
83
|
const smtpUrl = nonEmpty(env['SMTP_URL']);
|
|
@@ -98,8 +110,16 @@ export function selectMailDriver(env: MailEnvironment): MailSelection {
|
|
|
98
110
|
};
|
|
99
111
|
}
|
|
100
112
|
|
|
113
|
+
if (isLocal({ env })) {
|
|
114
|
+
return {
|
|
115
|
+
driver: createMemoryDriver(),
|
|
116
|
+
detail: 'caught in memory — set SMTP_URL or RESEND_API_KEY to deliver',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const environment = resolveEnvironment({ env });
|
|
101
121
|
return {
|
|
102
|
-
driver:
|
|
103
|
-
detail:
|
|
122
|
+
driver: createUnconfiguredDriver(environment),
|
|
123
|
+
detail: `no transport configured for ${environment} — set SMTP_URL or RESEND_API_KEY`,
|
|
104
124
|
};
|
|
105
125
|
}
|
package/src/driver-smtp.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import type { SendResult } from './driver';
|
|
13
13
|
import { envelopeRecipients, type MailDriver, type MailMessage, resultFor } from './driver';
|
|
14
14
|
import { sendFailed } from './errors';
|
|
15
|
+
import { mailMessageIdToken } from './idempotency';
|
|
15
16
|
import { addressDomain, addressSpec, buildMimeMessage } from './mime';
|
|
16
17
|
import {
|
|
17
18
|
type SmtpConnector,
|
|
@@ -159,10 +160,15 @@ export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
|
|
|
159
160
|
|
|
160
161
|
async function deliver(message: MailMessage): Promise<SendResult> {
|
|
161
162
|
// The Message-ID is the id the recipient's mailbox shows, so it is also the id we report: an
|
|
162
|
-
// SMTP `250` carries no identifier a caller could correlate with anything.
|
|
163
|
-
//
|
|
164
|
-
// is
|
|
165
|
-
|
|
163
|
+
// SMTP `250` carries no identifier a caller could correlate with anything.
|
|
164
|
+
//
|
|
165
|
+
// It is CONTENT-DERIVED, so every attempt of one send presents the same one. A `nanoid` here
|
|
166
|
+
// made a retry after a timeout past `DATA` a second, unrelated email — the case
|
|
167
|
+
// `SendResult.idempotencyKey` claims is one message, and the case Resend's `Idempotency-Key`
|
|
168
|
+
// header has always covered. It is a one-way digest and not the key itself, which is what
|
|
169
|
+
// answers the objection the key raises: the key holds the recipient list, blind ones included,
|
|
170
|
+
// and this header is visible to all of them.
|
|
171
|
+
const messageId = `<${message.mailId}.${mailMessageIdToken(message)}@${addressDomain(options.from)}>`;
|
|
166
172
|
const data = buildMimeMessage(message, {
|
|
167
173
|
from: options.from,
|
|
168
174
|
messageId,
|
package/src/driver.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// `driver-resend.ts`; swapping one for the other is an `app.config.ts` line and zero template
|
|
5
5
|
// changes.
|
|
6
6
|
|
|
7
|
-
import { nanoid, logger as rootLogger } from '@ultimat3/core';
|
|
8
|
-
import { driverUnavailable } from './errors';
|
|
7
|
+
import { type Environment, nanoid, logger as rootLogger } from '@ultimat3/core';
|
|
8
|
+
import { driverUnavailable, mailCredentialMissing } from './errors';
|
|
9
9
|
import { mailIdempotencyKey } from './idempotency';
|
|
10
10
|
|
|
11
11
|
/** The rendered envelope. Everything a transport needs; nothing it does not. */
|
|
@@ -131,6 +131,28 @@ export function isMemoryDriver(driver: MailDriver): driver is MemoryMailDriver {
|
|
|
131
131
|
);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
export const UNCONFIGURED_DRIVER_NAME = 'unconfigured';
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What a deployment outside development gets when it configured no transport. It is a driver and
|
|
138
|
+
* not a boot refusal so an app that sends no mail still deploys; it refuses on the send so an app
|
|
139
|
+
* that does sends nothing silently. The memory driver in that position answered `accepted` for
|
|
140
|
+
* every message — a password reset reported as delivered, with no error anywhere to find it by.
|
|
141
|
+
*/
|
|
142
|
+
export function createUnconfiguredDriver(environment: Environment): MailDriver {
|
|
143
|
+
return {
|
|
144
|
+
name: UNCONFIGURED_DRIVER_NAME,
|
|
145
|
+
// Rejected rather than thrown: `send()` and `sendMailJob` both await this, and a synchronous
|
|
146
|
+
// throw from a method typed as returning a promise escapes the caller's own error path.
|
|
147
|
+
send: (): Promise<SendResult> => Promise.reject(mailCredentialMissing(environment)),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Whether this process will refuse every send. A host prints a different boot line for it. */
|
|
152
|
+
export function isUnconfiguredDriver(driver: MailDriver): boolean {
|
|
153
|
+
return driver.name === UNCONFIGURED_DRIVER_NAME;
|
|
154
|
+
}
|
|
155
|
+
|
|
134
156
|
/**
|
|
135
157
|
* Structured log line per message through core's `logger` — the default for a worker that
|
|
136
158
|
* has no credentials yet. Bodies are never logged; a mail body is user data.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Single responsibility: what may appear inside an SMTP `MAIL FROM:<>` or `RCPT TO:<>`. One gate
|
|
2
|
+
// for the envelope, exactly as `mime.ts`'s `header()` is the one gate for the message — two wire
|
|
3
|
+
// formats, one check each, at the module that owns the format. Refuses; never rewrites.
|
|
4
|
+
|
|
5
|
+
import { addressInvalid, type EnvelopeAddressField } from './errors';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every character that can restructure the command line: C0 controls (CR and LF above all), DEL,
|
|
9
|
+
* the C1 range, and the angle brackets that delimit the address — a `>` closes the bracket early
|
|
10
|
+
* and turns whatever follows into ESMTP parameters.
|
|
11
|
+
*
|
|
12
|
+
* A space is deliberately NOT refused: RFC 5321 allows one inside a quoted local-part, and with
|
|
13
|
+
* the brackets already refused it can only produce an address the server itself rejects, never a
|
|
14
|
+
* second command. Non-ASCII is not refused either — whether a server takes a UTF-8 mailbox is
|
|
15
|
+
* SMTPUTF8's question and the server's answer, not a decision this check may make on its behalf.
|
|
16
|
+
*/
|
|
17
|
+
function isUnsafe(address: string): boolean {
|
|
18
|
+
for (let index = 0; index < address.length; index += 1) {
|
|
19
|
+
const code = address.charCodeAt(index);
|
|
20
|
+
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return true;
|
|
21
|
+
if (code === 0x3c || code === 0x3e) return true; // '<' and '>'
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Throws `X_MAIL_ADDRESS_INVALID` before a single byte of the envelope is written. */
|
|
27
|
+
export function assertEnvelopeAddress(field: EnvelopeAddressField, address: string): void {
|
|
28
|
+
if (isUnsafe(address)) throw addressInvalid(field);
|
|
29
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Mail fails in production, not in tests — a wrong locale, an empty text part or an
|
|
3
3
|
// unconfigured driver must name the exact call site edit that repairs it.
|
|
4
4
|
|
|
5
|
-
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
5
|
+
import { type Environment, registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
6
6
|
|
|
7
7
|
export const MAIL_ERROR_CODES = [
|
|
8
8
|
'X_MAIL_LOCALE_MISSING',
|
|
@@ -10,7 +10,9 @@ export const MAIL_ERROR_CODES = [
|
|
|
10
10
|
'X_MAIL_DUPLICATE',
|
|
11
11
|
'X_MAIL_TEXT_MISSING',
|
|
12
12
|
'X_MAIL_DRIVER_UNAVAILABLE',
|
|
13
|
+
'X_MAIL_CREDENTIAL_MISSING',
|
|
13
14
|
'X_MAIL_HEADER_INVALID',
|
|
15
|
+
'X_MAIL_ADDRESS_INVALID',
|
|
14
16
|
'X_MAIL_SEND_FAILED',
|
|
15
17
|
] as const;
|
|
16
18
|
|
|
@@ -22,7 +24,9 @@ export const MAIL_ERROR_TITLES: Readonly<Record<MailErrorCode, string>> = {
|
|
|
22
24
|
X_MAIL_DUPLICATE: 'two mails claim the same id',
|
|
23
25
|
X_MAIL_TEXT_MISSING: 'the rendered mail has no plain-text part',
|
|
24
26
|
X_MAIL_DRIVER_UNAVAILABLE: 'no mail driver is configured',
|
|
27
|
+
X_MAIL_CREDENTIAL_MISSING: 'this deployment configured no mail transport',
|
|
25
28
|
X_MAIL_HEADER_INVALID: 'a header value carries a line break',
|
|
29
|
+
X_MAIL_ADDRESS_INVALID: 'an envelope address could restructure the SMTP command line',
|
|
26
30
|
X_MAIL_SEND_FAILED: 'the mail transport refused the message',
|
|
27
31
|
};
|
|
28
32
|
|
|
@@ -66,7 +70,7 @@ export const templateUnknown = (mailId: string, known: readonly string[]): MailE
|
|
|
66
70
|
new MailError({
|
|
67
71
|
code: 'X_MAIL_TEMPLATE_UNKNOWN',
|
|
68
72
|
cause: `no mail with id "${mailId}" is registered (have: ${known.join(', ') || 'none'})`,
|
|
69
|
-
fix: `
|
|
73
|
+
fix: `export defineMail({ id: '${mailId}', ... }) and import that module at boot — the import IS the registration`,
|
|
70
74
|
meta: { mailId, known },
|
|
71
75
|
});
|
|
72
76
|
|
|
@@ -86,7 +90,7 @@ export const mailDuplicate = (mailId: string): MailError =>
|
|
|
86
90
|
new MailError({
|
|
87
91
|
code: 'X_MAIL_DUPLICATE',
|
|
88
92
|
cause: `mail id "${mailId}" is already registered by another defineMail() call`,
|
|
89
|
-
fix: `
|
|
93
|
+
fix: `rename one of the two defineMail({ id: '${mailId}' }) declarations — an id is the key both surfaces address a template by`,
|
|
90
94
|
meta: { mailId },
|
|
91
95
|
});
|
|
92
96
|
|
|
@@ -106,6 +110,28 @@ export const driverUnavailable = (what: string): MailError =>
|
|
|
106
110
|
meta: { what },
|
|
107
111
|
});
|
|
108
112
|
|
|
113
|
+
/**
|
|
114
|
+
* A DEPLOYMENT set neither credential, where `X_MAIL_DRIVER_UNAVAILABLE` is a WIRING bug — one is
|
|
115
|
+
* fixed by an operator setting a variable, the other by a developer calling `setMailDriver`, so
|
|
116
|
+
* they are two codes and not one cause string.
|
|
117
|
+
*
|
|
118
|
+
* Raised on the send rather than at boot, deliberately: a boot refusal turns a working deploy of an
|
|
119
|
+
* app that sends no mail into a failing one, while this lands on the exact path that needed the
|
|
120
|
+
* capability — the alternative being the memory driver reporting `accepted` for a password reset
|
|
121
|
+
* that never left the process.
|
|
122
|
+
*/
|
|
123
|
+
export const mailCredentialMissing = (environment: Environment): MailError =>
|
|
124
|
+
new MailError({
|
|
125
|
+
code: 'X_MAIL_CREDENTIAL_MISSING',
|
|
126
|
+
cause:
|
|
127
|
+
`neither SMTP_URL nor RESEND_API_KEY is set in ${environment}, so this process has no ` +
|
|
128
|
+
'transport — the message was not delivered and was not queued anywhere',
|
|
129
|
+
fix:
|
|
130
|
+
'set SMTP_URL="smtps://user:pass@host:465" (or RESEND_API_KEY=re_...) and ' +
|
|
131
|
+
'MAIL_FROM="App <no-reply@yourdomain.test>" in the deployment environment, then restart',
|
|
132
|
+
meta: { environment, missing: ['SMTP_URL', 'RESEND_API_KEY'] },
|
|
133
|
+
});
|
|
134
|
+
|
|
109
135
|
/**
|
|
110
136
|
* A CR or LF inside a header value ends the header early and lets whatever follows become new
|
|
111
137
|
* headers — the recipient list, a forged `From`. Interpolated data reaches `Subject`, so this is
|
|
@@ -121,6 +147,37 @@ export const headerInvalid = (name: string, mailId: string): MailError =>
|
|
|
121
147
|
meta: { header: name, mailId },
|
|
122
148
|
});
|
|
123
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Which half of the SMTP envelope an address belongs to. A closed union, and it lives here rather
|
|
152
|
+
* than beside the check for the same reason `SendStage` does: the two halves come from two
|
|
153
|
+
* different places — a config line and a `send()` call — so each needs its own `fix`, and a typo
|
|
154
|
+
* has to be a compile error instead of a lookup that quietly misses.
|
|
155
|
+
*/
|
|
156
|
+
export type EnvelopeAddressField = 'sender' | 'recipient';
|
|
157
|
+
|
|
158
|
+
const ADDRESS_FIXES: Readonly<Record<EnvelopeAddressField, string>> = {
|
|
159
|
+
sender: 'set mail.from in app.config.ts to a bare address, e.g. no-reply@example.test',
|
|
160
|
+
recipient: "pass bare addresses: send(mail, data, { to: ['ada@example.test'], locale })",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `MAIL FROM:<…>` and `RCPT TO:<…>` are built by interpolation, so a CR or LF in an address ends
|
|
165
|
+
* the command line and lets the rest of it run as SMTP commands of its own — arbitrary relay over
|
|
166
|
+
* the app's authenticated connection. Refused rather than stripped, like a header: a stripped
|
|
167
|
+
* address is a different address, so sanitising silently redirects the mail instead of stopping it.
|
|
168
|
+
* The value never appears here — an address is recipient data, which this package keeps out of
|
|
169
|
+
* every string it writes itself.
|
|
170
|
+
*/
|
|
171
|
+
export const addressInvalid = (field: EnvelopeAddressField): MailError =>
|
|
172
|
+
new MailError({
|
|
173
|
+
code: 'X_MAIL_ADDRESS_INVALID',
|
|
174
|
+
cause:
|
|
175
|
+
`the SMTP envelope ${field} address holds a control character or an angle bracket, ` +
|
|
176
|
+
'which would end the command line and inject SMTP commands',
|
|
177
|
+
fix: ADDRESS_FIXES[field],
|
|
178
|
+
meta: { field },
|
|
179
|
+
});
|
|
180
|
+
|
|
124
181
|
/**
|
|
125
182
|
* Every step a send can die at. A closed union rather than a `string`: the stage is keyed on by
|
|
126
183
|
* the transports' `fix` tables and asserted on in tests, so a typo has to be a compile error
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Single responsibility: refuse a `MailMessage` whose header-bound fields carry a CR or LF, before
|
|
2
|
+
// any transport sees it. The rule is a property of the MESSAGE and not of a wire format, so it is
|
|
3
|
+
// checked where a message is built rather than once per driver — it shipped only in `mime.ts`, so
|
|
4
|
+
// a subject an SMTP deploy rejected was accepted by memory in dev and by Resend in staging.
|
|
5
|
+
|
|
6
|
+
import { type MailMessage, messageHeaders } from './driver';
|
|
7
|
+
import { headerInvalid } from './errors';
|
|
8
|
+
|
|
9
|
+
/** A break ends the header early and everything after it becomes headers of the sender's choosing. */
|
|
10
|
+
const BREAK = /[\r\n]/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every field of `message` that becomes a header, through the one function that decides which
|
|
14
|
+
* headers a message gets — so a header added there is gated here without a second edit.
|
|
15
|
+
*
|
|
16
|
+
* `mime.ts` keeps its own per-header gate and is NOT redundant: it also covers `From`, `Date` and
|
|
17
|
+
* `Message-ID`, which the transport mints and no message-level check can see, and it is the last
|
|
18
|
+
* thing every present and future caller of `buildMimeMessage` passes through.
|
|
19
|
+
*/
|
|
20
|
+
export function assertHeaderSafe(message: MailMessage): void {
|
|
21
|
+
if (BREAK.test(message.subject)) throw headerInvalid('Subject', message.mailId);
|
|
22
|
+
for (const [name, value] of Object.entries(messageHeaders(message))) {
|
|
23
|
+
if (BREAK.test(value)) throw headerInvalid(name, message.mailId);
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/idempotency.ts
CHANGED
|
@@ -5,13 +5,19 @@
|
|
|
5
5
|
import type { MailMessage } from './driver';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* `(mailId, recipients, hash(rendered payload))`, or the caller's key when
|
|
9
|
-
* Content-derived on purpose: a retry of the same request produces the same key, while
|
|
10
|
-
* intentional resend with different content produces a different one.
|
|
8
|
+
* `(mailId, recipients, hash(rendered payload))`, or `(mailId, the caller's key)` when one is
|
|
9
|
+
* supplied. Content-derived on purpose: a retry of the same request produces the same key, while
|
|
10
|
+
* an intentional resend with different content produces a different one.
|
|
11
|
+
*
|
|
12
|
+
* The mailId is in BOTH branches, and the explicit one needs it most: a caller's key is a natural
|
|
13
|
+
* id from its own domain (`signup:42`, an order id), so the welcome mail and the verify-email mail
|
|
14
|
+
* about one signup would otherwise mint the same key — and the queue dedupes it (`onConflict:
|
|
15
|
+
* 'dedupe'`) and Resend dedupes it, so the second mail is never delivered and nothing reports it.
|
|
16
|
+
* Scoping to the template keeps the caller's dedupe where the caller meant it: this send, retried.
|
|
11
17
|
*/
|
|
12
18
|
export function mailIdempotencyKey(message: MailMessage): string {
|
|
13
19
|
const explicit = message.idempotencyKey;
|
|
14
|
-
if (explicit !== undefined && explicit !== '') return `mail:${explicit}`;
|
|
20
|
+
if (explicit !== undefined && explicit !== '') return `mail:${message.mailId}:${explicit}`;
|
|
15
21
|
const recipients = [...message.to].map((address) => address.toLowerCase()).sort();
|
|
16
22
|
// Every field that reaches the wire is hashed, `replyTo` included: it travels as `Reply-To` and
|
|
17
23
|
// as Resend's `reply_to`, so two mails that differ only there are two mails, and a shared key
|
|
@@ -32,6 +38,21 @@ export function mailIdempotencyKey(message: MailMessage): string {
|
|
|
32
38
|
return `mail:${message.mailId}:${recipients.join(',')}:${digest}`;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The `Message-ID` token for a message, stable across every attempt of the same send.
|
|
43
|
+
*
|
|
44
|
+
* SMTP has no idempotency protocol, so this header is the ONE identifier a receiving mailbox can
|
|
45
|
+
* collapse a duplicate on — and an attempt that times out after `DATA` is retryable, so the job
|
|
46
|
+
* retry hands the server the identical mail. A fresh random token per attempt made that second copy
|
|
47
|
+
* a different message to everything downstream.
|
|
48
|
+
*
|
|
49
|
+
* A one-way digest of the key, never the key itself: the key holds the recipient list, bcc
|
|
50
|
+
* included, and a `Message-ID` is visible to every recipient of the mail.
|
|
51
|
+
*/
|
|
52
|
+
export function mailMessageIdToken(message: MailMessage): string {
|
|
53
|
+
return contentDigest(mailIdempotencyKey(message));
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
/** Key order is normalised so two structurally equal payloads hash identically. */
|
|
36
57
|
function stableStringify(value: unknown): string {
|
|
37
58
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
package/src/index.ts
CHANGED
|
@@ -18,13 +18,16 @@ export type {
|
|
|
18
18
|
export {
|
|
19
19
|
createLogDriver,
|
|
20
20
|
createMemoryDriver,
|
|
21
|
+
createUnconfiguredDriver,
|
|
21
22
|
envelopeRecipients,
|
|
22
23
|
isMemoryDriver,
|
|
24
|
+
isUnconfiguredDriver,
|
|
23
25
|
mailDriver,
|
|
24
26
|
messageHeaders,
|
|
25
27
|
resetMailDriver,
|
|
26
28
|
setMailDriver,
|
|
27
29
|
tryMailDriver,
|
|
30
|
+
UNCONFIGURED_DRIVER_NAME,
|
|
28
31
|
} from './driver';
|
|
29
32
|
export type { MailEnvironment, MailSelection } from './driver-env';
|
|
30
33
|
export { MAIL_ENV_KEYS, selectMailDriver } from './driver-env';
|
|
@@ -32,23 +35,33 @@ export type { MailFetch, ResendDriverOptions } from './driver-resend';
|
|
|
32
35
|
export { createResendDriver, RESEND_BASE_URL } from './driver-resend';
|
|
33
36
|
export type { SmtpDriverOptions } from './driver-smtp';
|
|
34
37
|
export { createSmtpDriver } from './driver-smtp';
|
|
35
|
-
export
|
|
38
|
+
export { assertEnvelopeAddress } from './envelope-address';
|
|
39
|
+
export type {
|
|
40
|
+
EnvelopeAddressField,
|
|
41
|
+
MailErrorCode,
|
|
42
|
+
MailErrorInit,
|
|
43
|
+
SendFailure,
|
|
44
|
+
SendStage,
|
|
45
|
+
} from './errors';
|
|
36
46
|
export {
|
|
47
|
+
addressInvalid,
|
|
37
48
|
driverUnavailable,
|
|
38
49
|
layoutUnknown,
|
|
39
50
|
localeMissing,
|
|
40
51
|
MAIL_ERROR_CODES,
|
|
41
52
|
MAIL_ERROR_TITLES,
|
|
42
53
|
MailError,
|
|
54
|
+
mailCredentialMissing,
|
|
43
55
|
mailDuplicate,
|
|
44
56
|
sendFailed,
|
|
45
57
|
templateUnknown,
|
|
46
58
|
textMissing,
|
|
47
59
|
} from './errors';
|
|
60
|
+
export { assertHeaderSafe } from './header-safety';
|
|
48
61
|
|
|
49
62
|
export { escapeHtml, safeUrl } from './html';
|
|
50
63
|
|
|
51
|
-
export { mailIdempotencyKey } from './idempotency';
|
|
64
|
+
export { mailIdempotencyKey, mailMessageIdToken } from './idempotency';
|
|
52
65
|
export { mailMessageSchema, sendMailJob } from './job';
|
|
53
66
|
export type {
|
|
54
67
|
ColorScheme,
|
package/src/job.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { type JobHandle, job } from '@ultimat3/jobs';
|
|
6
6
|
import { type StandardSchemaV1, t } from '@ultimat3/schema';
|
|
7
7
|
import { type MailMessage, mailDriver, type SendResult } from './driver';
|
|
8
|
+
import { assertHeaderSafe } from './header-safety';
|
|
8
9
|
import { mailIdempotencyKey } from './idempotency';
|
|
9
10
|
|
|
10
11
|
/** The queue payload is the already-rendered envelope: rendering happens once, at send time. */
|
|
@@ -31,6 +32,18 @@ export const sendMailJob: JobHandle<MailMessage> = job<MailMessage>({
|
|
|
31
32
|
name: 'mail.send',
|
|
32
33
|
input: mailMessageSchema,
|
|
33
34
|
idempotencyKey: mailIdempotencyKey,
|
|
35
|
+
// A send carries a MailMessage — addresses and a rendered body, never an org. The recipient
|
|
36
|
+
// was resolved by whoever enqueued it, under their own tenant, so this run touches no
|
|
37
|
+
// tenant-scoped table and has no org to declare.
|
|
38
|
+
tenant: 'none',
|
|
34
39
|
retry: { attempts: 5, backoff: 'exponential' },
|
|
35
|
-
|
|
40
|
+
// `async`, so the refusal below is a REJECTED promise: a synchronous throw from a body whose
|
|
41
|
+
// signature promises one escapes every caller that only awaits it.
|
|
42
|
+
run: async ({ input }): Promise<SendResult> => {
|
|
43
|
+
// The second boundary the header rule is checked at, and the one `renderMessage` cannot cover:
|
|
44
|
+
// a queue row is not necessarily one this process rendered — `mailMessageSchema` proves the
|
|
45
|
+
// SHAPE of a payload and says nothing about a break inside a string it accepted.
|
|
46
|
+
assertHeaderSafe(input);
|
|
47
|
+
return await mailDriver().send(input);
|
|
48
|
+
},
|
|
36
49
|
});
|
package/src/mail.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { parse, type StandardSchemaV1 } from '@ultimat3/schema';
|
|
|
9
9
|
import type { MailTemplate } from './blocks';
|
|
10
10
|
import { type MailMessage, mailDriver, type SendResult } from './driver';
|
|
11
11
|
import { localeMissing, mailDuplicate, templateUnknown } from './errors';
|
|
12
|
+
import { assertHeaderSafe } from './header-safety';
|
|
12
13
|
import { mailIdempotencyKey } from './idempotency';
|
|
13
14
|
import { sendMailJob } from './job';
|
|
14
15
|
import { BASE_LAYOUT } from './layout';
|
|
@@ -94,8 +95,8 @@ export function resetMails(): void {
|
|
|
94
95
|
|
|
95
96
|
/**
|
|
96
97
|
* Validate, render, and build the envelope — everything `send` does before it decides
|
|
97
|
-
* between the queue and the transport. Exported so
|
|
98
|
-
*
|
|
98
|
+
* between the queue and the transport. Exported so a host or a test can render a mail without
|
|
99
|
+
* delivering it — the `/_x` mail panel and `mailIdempotencyKey`'s callers both need that.
|
|
99
100
|
*/
|
|
100
101
|
export function renderMessage<I>(
|
|
101
102
|
mail: MailDefinition<I>,
|
|
@@ -120,7 +121,7 @@ export function renderMessage<I>(
|
|
|
120
121
|
unsubscribeUrl: options.unsubscribeUrl,
|
|
121
122
|
});
|
|
122
123
|
|
|
123
|
-
|
|
124
|
+
const message: MailMessage = {
|
|
124
125
|
mailId: mail.id,
|
|
125
126
|
to,
|
|
126
127
|
subject: rendered.subject,
|
|
@@ -134,6 +135,12 @@ export function renderMessage<I>(
|
|
|
134
135
|
unsubscribeUrl: options.unsubscribeUrl,
|
|
135
136
|
idempotencyKey: options.idempotencyKey,
|
|
136
137
|
};
|
|
138
|
+
// Here, not in a driver: interpolated data reaches `Subject`, and whether a break in it injects
|
|
139
|
+
// headers is a property of the message, not of whichever transport this deploy happens to run.
|
|
140
|
+
// Checked before the queue too, so the refusal lands on the `send()` call site that made it
|
|
141
|
+
// rather than on a worker three retries later.
|
|
142
|
+
assertHeaderSafe(message);
|
|
143
|
+
return message;
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
/**
|
package/src/smtp-client.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// network. Every refusal becomes `X_MAIL_SEND_FAILED` naming the stage and the server's own reply.
|
|
4
4
|
|
|
5
5
|
import { base64Utf8 } from './base64';
|
|
6
|
+
import { assertEnvelopeAddress } from './envelope-address';
|
|
6
7
|
import { type MailError, type SendStage, sendFailed } from './errors';
|
|
7
8
|
import {
|
|
8
9
|
authPlain,
|
|
@@ -169,6 +170,14 @@ export async function smtpDeliver(
|
|
|
169
170
|
envelope: SmtpEnvelope,
|
|
170
171
|
options: SmtpSessionOptions,
|
|
171
172
|
): Promise<SmtpReply> {
|
|
173
|
+
// Before a single byte: every address below is interpolated into a command line, so one holding
|
|
174
|
+
// a CR or LF would write commands of its own. `bcc` reaches here having passed through no schema
|
|
175
|
+
// at all on the inline send path, and the header gate in `mime.ts` never sees it — an envelope
|
|
176
|
+
// field is not a header. Checked here rather than at either caller, because this is the module
|
|
177
|
+
// that builds the line, and it is the last place every present and future caller passes through.
|
|
178
|
+
assertEnvelopeAddress('sender', envelope.from);
|
|
179
|
+
for (const recipient of envelope.recipients) assertEnvelopeAddress('recipient', recipient);
|
|
180
|
+
|
|
172
181
|
const talk = new Conversation(stream, options.timeoutMs);
|
|
173
182
|
await talk.expect('greeting', (code) => code === 220);
|
|
174
183
|
|
package/src/templates/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// Single responsibility: the framework's own transactional mails, gathered. `FRAMEWORK_MAILS`
|
|
2
|
-
// is
|
|
1
|
+
// Single responsibility: the framework's own transactional mails, gathered. `FRAMEWORK_MAILS` is
|
|
2
|
+
// the list a host projects them from — there is no `x mail` command, and the header claimed one.
|
|
3
|
+
// `templates.test.ts` holds every entry to a rendered case, so a mail added here and left
|
|
4
|
+
// untranslated is a failing test rather than a template nobody notices.
|
|
3
5
|
|
|
4
6
|
import type { AnyMailDefinition } from '../mail';
|
|
5
7
|
import { inviteMail } from './invite';
|