mail-time 5.2.0 โ†’ 5.3.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/README.md CHANGED
@@ -35,7 +35,7 @@ Many clients + one or more servers coexist behind the same `prefix` in the same
35
35
  - ๐Ÿ“ฎ **Email concatenation** โ€” fold same-`to` emails arriving inside a window into one letter.
36
36
  - ๐ŸŽ›๏ธ **One-line setup** โ€” built-in [presets](#settings-presets) for `transactional`, `otp`, `newsletter`, `marketing`, `notifications`, `alerts`.
37
37
  - ๐Ÿ›ข๏ธ **Three first-party storages** โ€” MongoDB, Redis, PostgreSQL. Plus a [custom-adapter contract](https://github.com/veliovgroup/mail-time/blob/master/docs/queue-api.md).
38
- - ๐Ÿ“ฆ **Bun โ‰ฅ 1.1.0 & Node โ‰ฅ 20.9.0** โ€” same code, both runtimes.
38
+ - ๐Ÿ“ฆ **Bun โ‰ฅ 1.1.0 & Node โ‰ฅ 14.19.3** โ€” same code, both runtimes. See [supported runtimes](#supported-runtimes).
39
39
  - ๐Ÿค– **Ships with AI agent skills** โ€” see [AI agent skills](#ai-agent-skills) below.
40
40
  - ๐Ÿ“ **Hand-tuned ESM + CJS + TypeScript declarations**.
41
41
  - ๐Ÿงช **99%+ Jest line coverage** (85% threshold enforced) + Mocha integration tests for every adapter.
@@ -274,11 +274,11 @@ process.on('SIGTERM', async () => {
274
274
  });
275
275
  ```
276
276
 
277
- Graceful destroy waits for JoSk's running queue scan, then in-flight SMTP; claim renewal keeps running while sends drain. `schedulerTimeout` defaults to 10 seconds for JoSk's running-handler wait; it does not bound JoSk's own storage scan, SMTP, or policy hooks. A timed-out handler or a failed JoSk shutdown (logged) returns `false`; the Promise never rejects. `destroy()` without `{ drain: true }` stops immediately: in-flight completions make no storage writes and their claims recover after `sendingTimeout`. See [JoSk 6.4 recovery and shutdown](https://github.com/veliovgroup/mail-time/blob/master/docs/tuning.md#josk-64-restarts-and-shutdown).
277
+ Graceful destroy waits for JoSk's running queue scan, then in-flight SMTP; claim renewal keeps running while sends drain. Sends still waiting for a `concurrency` slot are dropped at once and their rows stay unclaimed for the next scan, so in-flight SMTP does not count against `schedulerTimeout`. `schedulerTimeout` defaults to 10 seconds for JoSk's running-handler wait; it does not bound JoSk's own storage scan, SMTP, or policy hooks. A timed-out handler or a failed JoSk shutdown (logged) returns `false`; the Promise never rejects. `destroy()` without `{ drain: true }` stops immediately: in-flight completions make no storage writes and their claims recover after `sendingTimeout`. See [JoSk 6.4 recovery and shutdown](https://github.com/veliovgroup/mail-time/blob/master/docs/tuning.md#josk-64-restarts-and-shutdown).
278
278
 
279
279
  #### Pause / resume a server (backpressure)
280
280
 
281
- `pause()` stops this **server** instance from competing for the queue-drain lease without tearing it down (unlike `destroy()`). In-flight SMTP sends finish; other `server` instances keep draining. `resume()` resumes and triggers an immediate scan.
281
+ `pause()` stops this **server** instance from competing for the queue-drain lease without tearing it down (unlike `destroy()`). In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay queued; other `server` instances keep draining. `resume()` resumes and triggers an immediate scan.
282
282
 
283
283
  ```js
284
284
  mailQueue.pause(); // stop draining on this pod
@@ -425,7 +425,7 @@ new MailTime({
425
425
  });
426
426
  ```
427
427
 
428
- Only the SMTP envelope is filtered. Headers remain unchanged; some transports retain BCC in generated MIME. If suppressed addresses must not appear in message content, remove them from headers before enqueueing. See [recipient policy hooks, outcomes, recovery, and rollout](https://github.com/veliovgroup/mail-time/blob/master/docs/recipient-policies.md).
428
+ Each `to`, `cc`, `bcc`, and `from` entry must hold one mailbox, for example `"Doe, John" <user@example.com>` or `{ name, address }`; use arrays for several recipients. An address MailTime cannot parse fails the task without SMTP or further retries, with `error.code === 'MAIL_TIME_INVALID_ADDRESS'` and `error.field` naming the field. Only the SMTP envelope is filtered. Headers remain unchanged; some transports retain BCC in generated MIME. If suppressed addresses must not appear in message content, remove them from headers before enqueueing. See [recipient policy hooks, outcomes, recovery, and rollout](https://github.com/veliovgroup/mail-time/blob/master/docs/recipient-policies.md).
429
429
 
430
430
  ## Queue payload trust
431
431
 
@@ -519,14 +519,15 @@ await mailQueue.sendMail({
519
519
  | `shouldFailOver` | `(error, info, email) => boolean` | โ€” | Veto rotating to the next transport for this failure. Default: rotate unless the error carries `mayFailOver === false`. See [Transport fail-over](#transport-fail-over). |
520
520
  | `strictPayload` | `boolean` | `false` | Narrow every queued letter to `allowedMailFields` and force nodemailer's `disableFileAccess` / `disableUrlAccess`. See [Queue payload trust](#queue-payload-trust). |
521
521
  | `allowedMailFields` | `string[]` | โ€” | Extra field names permitted under `strictPayload` (added to the built-in allowlist). |
522
- | `verifyTransports` | `boolean` | `true` | Probe each transport via `transport.verify()` once at `ready()`. Failing transports are marked unusable, surfaced through `onError(error, null, { transportIndex, phase: 'verify' })`, and skipped during rotation/fallback. Throws from `ready()` if **every** transport fails. Transports without a `verify()` method are treated as healthy. |
522
+ | `verifyTransports` | `boolean` | `true` | Probe each transport via `transport.verify()` once at `ready()`. Failing transports are quarantined (skipped during rotation/fallback) and surfaced through `onError(error, null, { transportIndex, phase: 'verify' })`. Throws from `ready()` if **every** transport fails. Transports without a `verify()` method are treated as healthy. MailTime always calls `verify(callback)` with a `(error, success) => void` callback. The first of the callback, a returned thenable, a synchronous throw, or a synchronous `true`/`false` return decides. A `false` from Nodemailer (transport without `verify`) means healthy, and a callback `success` of `false` is ignored. Any other synchronous return value is ignored. A quarantined transport is re-probed in the background once its backoff elapses (60 s, doubling to 15 min, no option). A successful probe returns it to rotation. Sends never wait for a probe. Probes are single-flight: a probe that times out stays in flight until its verdict arrives, so at most one `verify()` is outstanding per transport. No probes with `verifyTransports: false`, on `type: 'client'`, or after `destroy()`. |
523
+ | `verifyTimeout` | `number` | `30000` | Milliseconds `ready()` waits for each `transport.verify()`. Positive numbers, including `Infinity`, are clamped to `2147483647`. Anything else (non-number, `0`, negative, `NaN`) uses `30000`. On timeout `ready()` stops waiting, logs one warning, and the transport keeps its health (usable if it was usable, quarantined if it was quarantined). No `onError`. A verdict that arrives later still applies: success clears a quarantine, failure quarantines the transport and calls `onError` once. A `verify()` that neither calls back nor returns a Promise delays `ready()` by this value and stays usable. |
523
524
  | `template` | `string` | `'{{{html}}}'` | Default envelope. |
524
525
  | `prefix` | `string` | `''` | Queue namespace. **Same** on every `client` and `server` for one logical queue; **different** per email class. Inherited by the queue adapter; JoSk scheduler uses `mailTimeQueue<prefix>`. |
525
526
  | `from` | `string \| (transport, details) => string` | โ€” | Strongly recommended for spam-passing `From:` formatting. `details` is `{ index, from }` โ€” see [Resolving the sender](#resolving-the-sender). |
526
527
  | `debug` | `boolean` | `false` | Verbose logs. |
527
528
  | `onSent(email, info)` | `function` | โ€” | Without policies, called once the task is fully delivered. Policy mode adds grouped `recipients, summary` arguments. `email.mailOptions[i].accepted` lists every address that got through (across all attempts). |
528
- | `onError(error, email, info)` | `function` | โ€” | Without policies, called once the retry budget is exhausted with at least one un-accepted recipient. Policy mode adds grouped `recipients, summary` arguments. `email.mailOptions[i].rejected` lists each un-delivered address with its last error. Also fires once per transport that fails `verify()` at startup with `email === null` and `info = { transportIndex, phase: 'verify' }`. |
529
- | `recipientPolicies` | `MailTimeRecipientPolicy[]` | โ€” | Optional nonempty provider list. See [Recipient policies](#recipient-policies). |
529
+ | `onError(error, email, details)` | `function` | โ€” | `details` is typed `MailTimeErrorDetails`: `{ phase?, transportIndex?, attempt? }` plus the SMTP `info` keys of a failed attempt. Without policies, called once the retry budget is exhausted with at least one un-accepted recipient. Policy mode adds grouped `recipients, summary` arguments. `email.mailOptions[i].rejected` lists each un-delivered address with its last error. Also fires once per transport that fails `verify()` at startup with `email === null` and `info = { transportIndex, phase: 'verify' }`. Also fires once when a storage write that records a send outcome throws, with `details.phase` of `'complete'` (final or retry-release write) or `'checkpoint'` (policy mode, recipient results after SMTP). |
530
+ | `recipientPolicies` | `MailTimeRecipientPolicy[]` | โ€” | Optional nonempty provider list. When set, `sendMail()` rejects an unparseable address (`MAIL_TIME_INVALID_ADDRESS`, `error.field` such as `to[1]`) before enqueue, and a `server` constructor throws for an unparseable string `from` or transport `from` (`transports[i].from`). See [Recipient policies](#recipient-policies). |
530
531
  | `onSuppressed(task, recipients, summary)` | `function` | โ€” | Terminal suppressed group in policy mode. |
531
532
  | `onRejected(task, recipients, summary)` | `function` | โ€” | Terminal permanently rejected group in policy mode. |
532
533
 
@@ -557,9 +558,9 @@ For deeper JoSk semantics, install the JoSk skill: **`npx skills add veliovgroup
557
558
  - `cancelMail(uuidOrPromise)` โ†’ `Promise<boolean>`. Accepts the `uuid` or the `Promise<string>` from `sendMail`.
558
559
  - `cancel(uuid)` โ€” alias of `cancelMail`.
559
560
  - `ping()` โ†’ `Promise<{status, code, statusCode, paused?, error?}>`. Pings scheduler then queue; `paused` reflects `isPaused`.
560
- - `ready()` โ†’ `Promise<MailTime>`. Awaits all startup work; rejects with `.cause` on storage failure.
561
- - `destroy(opts?)` โ†’ `boolean` or `Promise<boolean>` when `{ drain: true }`. Graceful shutdown waits for JoSk's scan and the SMTP pool; `schedulerTimeout` defaults to 10000 ms; a timed-out scan or failed JoSk shutdown resolves `false` (never rejects). Plain `destroy()` aborts completion writes.
562
- - `drain()` โ†’ `Promise<void>`. Resolves once every in-flight SMTP attempt finishes. Useful in tests and graceful-shutdown paths.
561
+ - `ready()` โ†’ `Promise<MailTime>`. Awaits all startup work; rejects on storage failure with the original error in `.cause` (`Error` `cause` is ignored below Node 16.9).
562
+ - `destroy(opts?)` โ†’ `boolean` or `Promise<boolean>` when `{ drain: true }`. Graceful shutdown waits for JoSk's scan and the SMTP pool; sends not yet started are dropped and stay queued; `schedulerTimeout` defaults to 10000 ms; a timed-out scan or failed JoSk shutdown resolves `false` (never rejects). Plain `destroy()` aborts completion writes.
563
+ - `drain()` โ†’ `Promise<{ failedWrites }>`. Resolves once every in-flight SMTP attempt finishes. `failedWrites` counts, since instance creation, storage writes that threw while recording an outcome, plus outcome writes lost after a claim-renewal error. Such a lost write is retried once with the renewal's stamp, except when the claim was already stale at renewal time; the reported message is `outcome write lost (renewal outcome uncertain or lease taken over)`. Each is also reported through `onError` with `details.phase` (`'complete'` or `'checkpoint'`), except after a plain `destroy()`, which still counts but does not call `onError`. Such a row can stay `sending` and be re-sent after `sendingTimeout`. Read it after `destroy({ drain: true })` with `await mailQueue.drain()` to learn whether shutdown was clean. Useful in tests and graceful-shutdown paths.
563
564
  - `pause()` / `resume()` โ†’ `boolean`. Server-only reversible backpressure; no-ops on `client` or after `destroy()`. See [Shutdown](#6-shutdown).
564
565
  - `isPaused` โ†’ `boolean`. Read-only; always `false` on `client`.
565
566
 
@@ -598,6 +599,7 @@ Upgrade checklists, adapter contract changes, and rollout notes live in the docs
598
599
  | 4.0 | 4.1 | [v4.0 โ†’ v4.1](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4-v4.1.md) |
599
600
  | 4.1 | 5.0 | [v4.1 โ†’ v5](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4.1-v5.md) |
600
601
  | 5.0 | 5.1 | [v5 โ†’ v5.1 Redis Cluster](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v5-v5.1.md) |
602
+ | 5.2 | 5.3 | [v5.2 โ†’ v5.3](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v5.2-v5.3.md) |
601
603
 
602
604
  New in v4 (opt-in): `mailTimePreset`, `concurrency`/`mode`, `sendingTimeout`, `drain()`/`pause()`/`resume()`, per-recipient handling, AI skills bundle.
603
605
 
@@ -622,9 +624,18 @@ bun test ./test/jest
622
624
 
623
625
  `npm test` runs Jest unit tests, then Mocha integration tests, then TypeScript declaration tests. Jest coverage threshold is **85%** across statements, branches, functions, and lines. GitHub Actions runs the matrix against `redis@^4` and `redis@^5`.
624
626
 
627
+ ## Supported runtimes
628
+
629
+ `engines.node` is `>=14.19.3`. The published package (ESM `index.js` and CJS `index.cjs`) is tested from a packed tarball with `test/runtime-matrix/run.sh` 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. Each run sends through a stub transport with `MongoQueue`, and checks load, shutdown, `pause()`/`resume()` and drain behavior (12 checks, all passing).
630
+
631
+ - Node 12 fails to parse the package (optional chaining). Node 14.0 to 14.16 lack `crypto.randomUUID`; 14.17 to 14.18 are untested.
632
+ - The dependency `josk@6.5.0` declares `engines.node >=14.21.3`. Package managers that enforce `engines` (`npm --engine-strict`, Yarn 1) reject the install on Node 14.19.3 to 14.21.2.
633
+ - Store drivers set their own floor. `mongodb` 7 needs Node 20.19+, `mongodb` 6 needs 16.20.1+, `mongodb` 5 needs 14.20.1+; `redis` 5 needs 18.19+; `pg` 8 needs 16+. The Node 14 and 16 runs used `mongodb@3.7.4`. Only `MongoQueue` is tested below Node 18. `RedisQueue` and `PostgresQueue` load on those versions but are untested there.
634
+ - The repository's own test suite (Jest 30, Mocha 11, TypeScript 6) needs Node 20 or later (dev toolchain). CI runs it on Node 20.9, 22 and LTS, and runs the packed-artifact matrix on Node 14.19.3, 16.20.2 and 18.19.1.
635
+
625
636
  ## Bun
626
637
 
627
- MailTime ships pure ESM with a generated CJS bundle. Both runtimes (Bun โ‰ฅ 1.1.0, Node โ‰ฅ 20.9.0) load it directly:
638
+ MailTime ships pure ESM with a generated CJS bundle. Both runtimes (Bun โ‰ฅ 1.1.0, Node โ‰ฅ 14.19.3) load it directly:
628
639
 
629
640
  ```js
630
641
  import { MailTime } from 'mail-time'; // works in both
package/adapters/mongo.js CHANGED
@@ -234,7 +234,7 @@ class MongoQueue {
234
234
  }
235
235
 
236
236
  try {
237
- while (await cursor.hasNext()) {
237
+ while (!this.mailTimeInstance.___isStopped && await cursor.hasNext()) {
238
238
  await this.mailTimeInstance.___dispatch(await cursor.next());
239
239
  }
240
240
  } finally {
@@ -273,6 +273,9 @@ class PostgresQueue {
273
273
  LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
274
274
 
275
275
  for (const row of res.rows || []) {
276
+ if (this.mailTimeInstance.___isStopped) {
277
+ break;
278
+ }
276
279
  await this.mailTimeInstance.___dispatch(normalizeRow(row));
277
280
  }
278
281
  } catch (iterateError) {
package/adapters/redis.js CHANGED
@@ -485,6 +485,9 @@ class RedisQueue {
485
485
  return;
486
486
  }
487
487
  for (const value of candidates) {
488
+ if (this.mailTimeInstance.___isStopped) {
489
+ break;
490
+ }
488
491
  const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
489
492
  if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
490
493
  await this.mailTimeInstance.___dispatch(candidate);
@@ -505,6 +508,9 @@ class RedisQueue {
505
508
  for await (const cursorValue of cursor) {
506
509
  const sendatKeys = Array.isArray(cursorValue) ? cursorValue : [cursorValue];
507
510
  for (const sendatKey of sendatKeys) {
511
+ if (this.mailTimeInstance.___isStopped) {
512
+ break outer;
513
+ }
508
514
  const raw = await this.client.get(sendatKey);
509
515
  if (raw === null || parseInt(raw, 10) > now) {
510
516
  continue;