@ultimat3/mail 7.0.0 → 9.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 +5 -0
- package/README.md +1 -1
- package/package.json +6 -6
- package/src/driver-resend.ts +11 -3
- package/src/driver-smtp.ts +7 -1
- package/src/driver.ts +24 -3
- package/src/index.ts +1 -0
package/CLAUDE.md
CHANGED
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
- No hex outside `MAIL_TOKENS`. Base styling is inlined (clients strip `<style>`); dark mode is
|
|
38
38
|
one `prefers-color-scheme` block keyed on short `data-x` role codes.
|
|
39
39
|
- Never format a date without `options.tz`. The `Date:` header is UTC, stated as `+0000`.
|
|
40
|
+
- **Every "now" in this package comes from a `Clock`, `createMemoryDriver` included** (`As of
|
|
41
|
+
2026-08-22`). It stamped `at: new Date()`, and `SentMail.at` is what `outbox()`, `lastTo()` and
|
|
42
|
+
the `/_x` panel ORDER on — so the one fact a test most needs to state was the one it could only
|
|
43
|
+
observe, and two sends inside one millisecond tied. `createMemoryDriver({ clock })`, the same
|
|
44
|
+
options shape `@ultimat3/jobs`' identically-named driver takes; omitted, it is `systemClock`.
|
|
40
45
|
- New block kind: `MailBlock` + `blocks` + `htmlOf` + `textOf`, same commit.
|
|
41
46
|
- A transport failure is `sendFailed({ stage, status, retryable, fix })` — never a bare throw, and
|
|
42
47
|
never a `retryable` guess. **`retryable` becomes the error's `retry` classification, and
|
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ delivers inline only when `{ sync: true }` is passed or no job driver is configu
|
|
|
43
43
|
|
|
44
44
|
| Driver | Use | Behaviour |
|
|
45
45
|
|---|---|---|
|
|
46
|
-
| `createMemoryDriver()` | dev, tests | retains messages; `outbox()` / `lastTo()` feed the `/_x` mail panel |
|
|
46
|
+
| `createMemoryDriver({ clock? })` | dev, tests | retains messages; `outbox()` / `lastTo()` feed the `/_x` mail panel, ordered by `SentMail.at` — pass a `frozenClock()` to choose it |
|
|
47
47
|
| `createLogDriver()` | workers without credentials | one structured line per message through core's `logger`; bodies never logged |
|
|
48
48
|
| `createUnconfiguredDriver(env)` | a deploy that configured no transport | refuses every send with `X_MAIL_CREDENTIAL_MISSING`; delivers nothing and claims nothing |
|
|
49
49
|
| `createSmtpDriver({ url, from })` | prod | real ESMTP over `Bun.connect`: STARTTLS, `AUTH PLAIN`/`LOGIN`, quoted-printable MIME |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mail",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.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",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/i18n": "
|
|
36
|
-
"@ultimat3/jobs": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "9.0.0",
|
|
35
|
+
"@ultimat3/i18n": "9.0.0",
|
|
36
|
+
"@ultimat3/jobs": "9.0.0",
|
|
37
|
+
"@ultimat3/schema": "9.0.0",
|
|
38
|
+
"@ultimat3/time": "9.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/driver-resend.ts
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
// MailDriver contract — no SDK, `fetch` is the whole client. Construction fails fast on a
|
|
3
3
|
// missing key or an empty from address, so a misconfiguration never waits for the first send.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ConfigInvalidError,
|
|
7
|
+
EnvMissingError,
|
|
8
|
+
logger,
|
|
9
|
+
nanoid,
|
|
10
|
+
renderThrowable,
|
|
11
|
+
} from '@ultimat3/core';
|
|
6
12
|
import type { MailDriver, MailMessage, SendResult } from './driver';
|
|
7
13
|
import { messageHeaders, resultFor } from './driver';
|
|
8
14
|
import { sendFailed } from './errors';
|
|
@@ -156,8 +162,10 @@ export function createResendDriver(options: ResendDriverOptions): MailDriver {
|
|
|
156
162
|
} catch (error) {
|
|
157
163
|
// DNS/TLS/reset, or the timeout firing — none of them got far enough to have a status,
|
|
158
164
|
// and every one of them can succeed unchanged on the job's next attempt.
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
// `renderThrowable`, never `error instanceof Error` and `.message`: `fetch` is injected
|
|
166
|
+
// and the peer is a third party, so `instanceof` RUNS a `Proxy`'s `getPrototypeOf` trap
|
|
167
|
+
// and its throw escapes this `catch` — the queue's dead-letter row would lose the code.
|
|
168
|
+
const reason = renderThrowable(error);
|
|
161
169
|
throw sendFailed({
|
|
162
170
|
driver: 'resend',
|
|
163
171
|
stage: 'request',
|
package/src/driver-smtp.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
ConfigInvalidError,
|
|
8
8
|
isUltimateError,
|
|
9
9
|
nanoid,
|
|
10
|
+
renderThrowable,
|
|
10
11
|
systemClock,
|
|
11
12
|
} from '@ultimat3/core';
|
|
12
13
|
import type { SendResult } from './driver';
|
|
@@ -210,6 +211,11 @@ export function createSmtpDriver(options: SmtpDriverOptions): MailDriver {
|
|
|
210
211
|
}
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/**
|
|
215
|
+
* `renderThrowable`, never `instanceof` + `.message` or `String(value)`: both RUN code on the
|
|
216
|
+
* caught value — a prototype read and a `toString` — and a throw here would escape the `catch`
|
|
217
|
+
* that exists to keep `X_MAIL_SEND_FAILED` on a dropped connection.
|
|
218
|
+
*/
|
|
213
219
|
function messageOf(error: unknown): string {
|
|
214
|
-
return
|
|
220
|
+
return renderThrowable(error);
|
|
215
221
|
}
|
package/src/driver.ts
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
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 {
|
|
7
|
+
import {
|
|
8
|
+
type Clock,
|
|
9
|
+
type Environment,
|
|
10
|
+
nanoid,
|
|
11
|
+
logger as rootLogger,
|
|
12
|
+
systemClock,
|
|
13
|
+
} from '@ultimat3/core';
|
|
8
14
|
import { driverUnavailable, mailCredentialMissing } from './errors';
|
|
9
15
|
import { mailIdempotencyKey, mailMessageIdToken } from './idempotency';
|
|
10
16
|
|
|
@@ -94,14 +100,29 @@ export interface MemoryMailDriver extends MailDriver {
|
|
|
94
100
|
clear(): void;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The same `{ clock }` shape `@ultimat3/jobs`' `createMemoryDriver` takes, deliberately: two
|
|
105
|
+
* in-memory drivers named the same thing in one test file may not want two spellings of "freeze
|
|
106
|
+
* time". An options object rather than a positional argument so the next seam is additive.
|
|
107
|
+
*/
|
|
108
|
+
export interface MemoryMailDriverOptions {
|
|
109
|
+
readonly clock?: Clock;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `at` comes from the clock, never from `new Date()`. `SentMail.at` is what `outbox()` and the
|
|
114
|
+
* `/_x` panel ORDER on, so a suite asserting which message is newest could only race the wall
|
|
115
|
+
* clock — two sends inside one millisecond tie, and nothing could state the intended order.
|
|
116
|
+
*/
|
|
117
|
+
export function createMemoryDriver(options: MemoryMailDriverOptions = {}): MemoryMailDriver {
|
|
118
|
+
const clock = options.clock ?? systemClock;
|
|
98
119
|
const sent: SentMail[] = [];
|
|
99
120
|
return {
|
|
100
121
|
name: 'memory',
|
|
101
122
|
sent,
|
|
102
123
|
send(message: MailMessage): Promise<SendResult> {
|
|
103
124
|
const result = resultFor('memory', message, `mem_${nanoid(12)}`);
|
|
104
|
-
sent.push({ at:
|
|
125
|
+
sent.push({ at: clock.now(), message, result });
|
|
105
126
|
return Promise.resolve(result);
|
|
106
127
|
},
|
|
107
128
|
outbox: () => [...sent].reverse(),
|