@ultimat3/mail 7.0.0 → 8.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 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": "7.0.0",
3
+ "version": "8.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": "7.0.0",
35
- "@ultimat3/i18n": "7.0.0",
36
- "@ultimat3/jobs": "7.0.0",
37
- "@ultimat3/schema": "7.0.0",
38
- "@ultimat3/time": "7.0.0"
34
+ "@ultimat3/core": "8.0.0",
35
+ "@ultimat3/i18n": "8.0.0",
36
+ "@ultimat3/jobs": "8.0.0",
37
+ "@ultimat3/schema": "8.0.0",
38
+ "@ultimat3/time": "8.0.0"
39
39
  }
40
40
  }
@@ -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 { ConfigInvalidError, EnvMissingError, logger, nanoid } from '@ultimat3/core';
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
- const reason =
160
- error instanceof Error ? error.message : 'the request failed before a response';
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',
@@ -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 error instanceof Error ? error.message : String(error);
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 { type Environment, nanoid, logger as rootLogger } from '@ultimat3/core';
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
- export function createMemoryDriver(): MemoryMailDriver {
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: new Date(), message, result });
125
+ sent.push({ at: clock.now(), message, result });
105
126
  return Promise.resolve(result);
106
127
  },
107
128
  outbox: () => [...sent].reverse(),
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export type {
12
12
  MailDriver,
13
13
  MailMessage,
14
14
  MemoryMailDriver,
15
+ MemoryMailDriverOptions,
15
16
  SendResult,
16
17
  SentMail,
17
18
  } from './driver';