Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a928a63
test: pin quoted-address parsing against Nodemailer's parser and head…
dr-dimitru Sep 28, 2026
eed697f
Add verifyTimeout option and transport verification
dr-dimitru Sep 29, 2026
0284333
Merge 5.2.2 address parsing fix and tests into 5.3 line
dr-dimitru Sep 29, 2026
0360e72
Surface completion-write failures via onError and drain() result
dr-dimitru Sep 29, 2026
3f2d3f9
test: add packed-artifact runtime matrix with JoSk-only and backlog s…
dr-dimitru Sep 28, 2026
8b2756d
Keep completion wording in write-failure log; matrix filter and Node …
dr-dimitru Sep 29, 2026
951102d
Lower engines.node to 14.19.3 with tested runtime matrix, docs and CI…
dr-dimitru Sep 29, 2026
3f980b7
Drop expected-fail Node 12 row from runtime matrix
dr-dimitru Sep 29, 2026
3e8ee27
docs: ping() reports scheduler and queue status only, not transport h…
dr-dimitru Sep 29, 2026
12ec408
Verify: first-verdict probes, timeout keeps health, late verdicts app…
dr-dimitru Sep 29, 2026
faeb653
Policy lease: a throwing renewal write stops renewals but keeps the o…
dr-dimitru Sep 29, 2026
5591349
Test: drain() resolves { failedWrites }
dr-dimitru Sep 29, 2026
69cbc3a
Runtime matrix: guard empty MT_ONLY, print source commit and dirty state
dr-dimitru Sep 29, 2026
e60067a
Docs and 5.3.0 version files: verify semantics, re-probe, address par…
dr-dimitru Sep 29, 2026
cf0b6a6
Test: drive re-probe backoff without fake timers so the suite runs un…
dr-dimitru Sep 29, 2026
047ee2e
Policy lease: surface a lost outcome write after an uncertain renewal…
dr-dimitru Sep 29, 2026
86b8f50
Recover and report a lost outcome write after an uncertain claim rene…
dr-dimitru Sep 29, 2026
8edc7b0
Uncertain renewal retry: only for live leases, skip after destroy, cl…
dr-dimitru Sep 29, 2026
86c9009
Report a lost outcome write on a stale claim after a renewal error; r…
dr-dimitru Sep 29, 2026
172adcc
Docs: stale-claim rule in drain wording; policy stale-claim test; cle…
dr-dimitru Sep 29, 2026
41bda03
📔 docs: agents / add testing traps and verify bun parity claim
dr-dimitru Sep 30, 2026
f251df1
Merge branch 'master' into next-5.3
dr-dimitru Sep 30, 2026
4409f3c
Merge branch 'next-5.3' into next-5.3-verify
dr-dimitru Sep 30, 2026
ed9abda
📔 docs: agents / add lease scenario harness trap
dr-dimitru Sep 30, 2026
8485ef7
📦 build: deps / upgrade josk to 6.5.0
dr-dimitru Sep 30, 2026
3f251cb
Merge remote-tracking branch 'origin/master' into next-5.3
dr-dimitru Sep 30, 2026
5a8da04
✨ feat: recipient-policies / auto-quote display names with specials
dr-dimitru Sep 30, 2026
72fd23c
Merge remote-tracking branch 'origin/master' into next-5.3
dr-dimitru Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,57 @@ jobs:
- name: Redis Cluster integration
run: npm run test:mocha:redis-cluster

node-floor:
name: node ${{ matrix.node }} · packed artifact · mongodb ${{ matrix.mongodb }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- node: '14.19.3'
mongodb: '3.7.4'
- node: '16.20.2'
mongodb: '3.7.4'
- node: '18.19.1'
mongodb: '6.21.0'
services:
mongo:
image: mongo:7
ports:
- 27017:27017
steps:
- uses: actions/checkout@v4

# Build and install with a current Node; only the probe runs on the floor version.
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm

- run: npm ci

- name: Pack mail-time and josk, install into a scratch project
run: |
mkdir -p "$RUNNER_TEMP/floor"
npm pack --pack-destination "$RUNNER_TEMP/floor" >/dev/null
JOSK_VERSION="$(node -p "require('./node_modules/josk/package.json').version")"
(cd "$RUNNER_TEMP/floor" && npm pack "josk@${JOSK_VERSION}" >/dev/null)
cd "$RUNNER_TEMP/floor"
cat > package.json <<JSON
{"name":"floor","private":true,"type":"module","dependencies":{"mail-time":"file:$(ls mail-time-*.tgz)","josk":"file:$(ls josk-*.tgz)","mongodb":"${{ matrix.mongodb }}"},"overrides":{"josk":"file:$(ls josk-*.tgz)"}}
JSON
npm install --no-audit --no-fund --ignore-scripts --engine-strict=false
cp "$GITHUB_WORKSPACE/test/runtime-matrix/probe.mjs" .

- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}

- name: Probe on Node ${{ matrix.node }}
working-directory: ${{ runner.temp }}/floor
run: node probe.mjs mongodb://127.0.0.1:27017/mtfloor

meteor-mongo:
name: meteor ${{ matrix.meteor-release }} · mongo (built-in)
runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions .versions
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ fetch@0.2.0
geojson-utils@1.0.12
id-map@1.2.0
inter-process-messaging@0.1.3
local-test:ostrio:mailer@5.2.2
local-test:ostrio:mailer@5.3.0
logging@1.3.6
meteor@2.3.1
meteortesting:browser-tests@1.8.0
Expand All @@ -39,7 +39,7 @@ mongo-dev-server@1.1.1
mongo-id@1.0.9
npm-mongo@6.16.3
ordered-dict@1.2.0
ostrio:mailer@5.2.2
ostrio:mailer@5.3.0
promise@1.0.0
random@1.2.2
react-fast-refresh@0.3.0
Expand Down
13 changes: 11 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ const sayName = (name) => {

## Standards
- Terse. No obvious comments. Exact adapter API compliance.
- ESM primary; JSDoc on public API; CJS generated. Node ≥ 20.9.0, Bun ≥ 1.1.0.
- ESM primary; JSDoc on public API; CJS generated. Node ≥ 14.19.3 (runtime; dev toolchain needs Node 20+), Bun ≥ 1.1.0.
- JSDoc on source drives `.d.ts`. Mark internal methods with the `___`/`__` prefix; they get stripped.
- Strict validation in constructors. Throw with `[mail-time] [<scope>]` prefix.
- One runtime dep: `josk`. **Don't add deps** without strong reason — the package's selling points are "tiny, no fluff".
Expand Down Expand Up @@ -157,9 +157,18 @@ REDIS_URL=redis://127.0.0.1:6379 MONGO_URL=mongodb://127.0.0.1:27017/test PG_URL

- Jest threshold: 85% statements/branches/functions/lines. Don't drop it.
- Add tests for any change. Cover both happy path and at least one failure path.
- Bun: `bun test ./test/jest` runs the Jest suite under Bun's runner.
- Bun: `bun test ./test/jest` runs the same `*.test.js` files and test count as `npm run test:jest` under Bun's runner.
- Live-SMTP recipient for integration tests: use `{random}@md5hashing.net` (the domain accepts every inbound recipient). For a guaranteed reject use `${randomUUID()}@${randomUUID()}.invalid`.

### Testing traps

Each of these looks like a code bug but comes from the test setup.

- `createQueue()` in `test/jest/helpers.js` stores a shallow copy on `push()`, and tests pass `{ ...queue.records.get(uuid) }` to `___send`, so the task and the stored row share one `mailOptions` array that `___trackAcceptedRecipients` changes (`mailOptions[i].accepted`) before any storage write; strip `mailOptions` before asserting a stored row is untouched.
- Every queued row starts with `isSettled: false` (set by `___addToQueue`), and the policy claim writes `recipientResults: []` before the `beforeSend` checkpoint fills it before SMTP, so never assert `undefined` on either field.
- In agent shells Jest 30 switches to its agent reporter, which prints only failing files and the summary with no per-test check marks, so list failing test names with `--json --outputFile <file>`.
- For ad-hoc lease or outcome-write scenarios, copy the harness shape from `run()` in `test/jest/claim-renew-uncertain.test.js`, run it in a `/tmp` copy of the repo with `node_modules` symlinked, and delete the copy afterwards.

## Guidelines
- Read `docs/queue-api.md` + existing adapters + tests before touching `adapters/`.
- New adapter: copy `adapters/blank-example.js`, add an entry to `README.md` storage matrix, write Jest unit tests, regenerate types.
Expand Down
30 changes: 27 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,33 @@
# Changelog

## 5.2.2
## 5.3.0

- Recipient policies accept RFC 5322 display names: quoted names with commas, escaped quotes, or specials, such as `"ostr.io" <no-reply@ostr.io>` and `"Doe, John" <user@example.com>`. 5.2.1 rejected them before SMTP.
- Address errors name the actual field (`from`, `to[1]`, `envelope.from`, …) through `error.field` and `error.code === 'MAIL_TIME_INVALID_ADDRESS'`. 5.2.1 blamed `envelope.to` for every field. Messages never include the address or display name.
Transport verification

- New `verifyTimeout` option (default `30000` ms). Positive numbers are accepted; values above `2147483647`, including `Infinity`, are clamped to `2147483647`. Anything else (non-number, `0`, negative, `NaN`, a numeric string) uses `30000`. There is no "wait forever" value. The timer is unref'd and cleared on `destroy()`.
- `verifyTransports` always passes a callback to `transport.verify(callback)`, so callback-only custom transports verify correctly. The first of the callback, a returned thenable, a synchronous throw, or a synchronous `true`/`false` return decides the probe. `false` is what Nodemailer's `Mailer#verify` returns when the underlying transport has no `verify` method (`jsonTransport`, `streamTransport`, `sendmail`), and counts as healthy. Any other synchronous return value is ignored, so a callback that reports an error later still counts.
- Behavior change: a `verify()` that neither calls back nor returns a Promise (including a synchronous `verify()` that returns `undefined` and ignores its callback) now delays `ready()` by `verifyTimeout` and then stays usable. It logs one warning and does not call `onError`.
- Behavior change: a timeout no longer marks the transport unusable. `ready()` stops waiting; health stays as it was. A verdict that arrives later still applies: a late success clears a quarantine, a late failure quarantines the transport and calls `onError(error, null, { phase: 'verify' })` once. A newer probe of the same transport always wins over an older late result. Verdicts after `destroy()` are ignored.
- Behavior change: a sole SMTP host that accepts connections but never answers (blackhole) resolves `ready()` after `verifyTimeout` and stays usable. Before, `ready()` rejected after about 30 s (Nodemailer's default `greetingTimeout`). The roughly 120 s figure applies only to a host that never completes the TCP connection. The failure still surfaces through `onError` when Nodemailer reports it after the timeout.
- Quarantined transports recover. When a send or rotation check finds a quarantined transport whose backoff has elapsed, one background `verify()` probe starts without delaying the send. Backoff starts at 60 s, doubles per failed or timed-out probe, and caps at 15 min (no option). Success returns the transport to rotation and logs one line. Under `strategy: 'backup'`, new sends route back to the recovered primary. Failures write to the debug log only. `onError` fires once per quarantine episode. No probes for `type: 'client'`, `verifyTransports: false`, or after `destroy()`. Before, a transport that failed verification stayed unusable until restart.

Completion writes and drain

- `drain()` resolves `{ failedWrites }` instead of `undefined`. `failedWrites` counts failures while recording a send outcome (a storage write that threw, or an outcome write lost after a claim-renewal error), cumulatively since the instance was created, and still counts after a plain `destroy()`.
- `onError(error, task, details)` fires once for each such write failure with `details.phase` of `'complete'` (final or retry-release write) or `'checkpoint'` (recipient-policy results written after SMTP). Previously the failure was only logged, so a shutdown drain looked clean while the row stayed `sending` and was re-sent after `sendingTimeout`. Delivery stays at-least-once.
- Recipient-policy mode: a claim-renewal write that throws stops further renewals only. The outcome write still runs. Before, the lease closed, no outcome was written, and the row stayed `sending` with no `onError` and `failedWrites` at 0, which produced a silent duplicate send later.
- Both modes: a claim-renewal write that throws may have been applied by storage, which made the outcome write fail its lease guard silently. MailTime now retries the outcome write once with the attempted renewal stamp (a peer takeover bumps `tries` or stamps a later `sendingAt` while the lease is live, so the retry cannot match a taken-over row; a claim that was already stale at renewal time is reported without a retry). A clean recovery reports nothing. If the retry fails too, `onError` receives `outcome write lost (renewal outcome uncertain or lease taken over)` with `details.phase` `'complete'` or `'checkpoint'` and `failedWrites` increments.

Runtimes

- `engines.node` is `>=14.19.3` (was `>=20.9.0`). The package loads and passes 12 send, shutdown and drain checks from a packed tarball on Node 14.19.3, 14.21.3, 16.20.2, 18.19.1, 20.11.1, 22.21.1, 24.16.0 and Bun. Node 12 cannot parse it. `josk` 6.5.0 declares `>=14.21.3`, so engine-strict installs need Node 14.21.3+. See "Supported runtimes" in the README.
- `josk` dependency raised to `^6.5.0` (was `^6.4.0`).

Recipient policies (from 5.2.2, never published separately)

- Recipient policies accept RFC 5322 display names with quoted parts, such as `"ostr.io" <no-reply@ostr.io>`, `"Doe, John" <user@example.com>` and `"A \"B\" C" <user@example.com>`. 5.2.1 rejected them before SMTP.
- Display names that contain an unquoted `@`, `[`, `]`, `\`, `(` or `)`, such as `a@b.com <c@d.com>` or `John (Sales) <x@y.com>`, are sent with the name quoted (`"John (Sales)" <x@y.com>`), so the header shows the mailbox the policy checked. 5.2.1 accepted these but sent the raw string, which Nodemailer rendered without comments, or without the address at all after an unbalanced `(`. Stored `mailOptions` keep the original string. Unquoted `<`, `>`, `:`, `;` and `,` in a display name stay rejected. Without `recipientPolicies` nothing changes.
- Address errors name the actual field (`from`, `to[1]`, `envelope.from`, and so on) through `error.field` and `error.code === 'MAIL_TIME_INVALID_ADDRESS'`. 5.2.1 blamed `envelope.to` for every field. Messages never include the address or display name.
- An unparseable address fails the task on its current attempt with a logged diagnostic and `onError`, instead of silently retrying until `maxTries`.
- Line breaks and NUL characters in `{ name }` objects are rejected like those in address strings.

Expand Down
Loading
Loading