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 +22 -11
- package/adapters/mongo.js +1 -1
- package/adapters/postgres.js +3 -0
- package/adapters/redis.js +6 -0
- package/index.cjs +556 -141
- package/index.d.cts +21 -77
- package/index.d.ts +21 -77
- package/index.js +342 -99
- package/package.json +5 -4
- package/presets.js +15 -15
- package/recipient-policy-lease.js +40 -8
- package/recipient-policy.js +151 -20
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 โฅ
|
|
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
|
|
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,
|
|
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`
|
|
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<
|
|
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 โฅ
|
|
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
package/adapters/postgres.js
CHANGED
|
@@ -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;
|