mail-time 5.3.0 → 5.3.1

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
@@ -176,7 +176,7 @@ Three things every MailTime needs: a connected storage **client**, one or more *
176
176
  // ESM
177
177
  import { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } from 'mail-time';
178
178
 
179
- // CommonJS
179
+ // CommonJS (subpaths such as require('mail-time/presets') resolve too, 5.3.1+)
180
180
  const { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } = require('mail-time');
181
181
  ```
182
182
 
@@ -374,13 +374,13 @@ Defaults fit moderate traffic in a single region. Reach for a [preset](#settings
374
374
  | `mode` | `'batch'` | `'one'` to claim a single row per tick (fairness over throughput across cluster nodes) |
375
375
  | `concurrency` | `1` | Raise to send N emails in parallel per instance. CAS blocks concurrent claims; SMTP rate limits still apply. |
376
376
  | `sendingTimeout` | `300000` (5 min) | Stale-lock recovery window. Must exceed worst-case SMTP roundtrip; MailTime logs a warning below `120000`. |
377
- | `renewClaim` | `sendingTimeout / 3` | Set `false` to go back to a single stamp at claim time. Lower it if your storage round-trip is slow relative to `sendingTimeout`. |
377
+ | `renewClaim` | `sendingTimeout / 3` (min 1000 ms) | Set `false` to go back to a single stamp at claim time. Lower it if your storage round-trip is slow relative to `sendingTimeout`. |
378
378
  | `maxRenewals` | `10` | Renewal-attempt ceiling. Recovery begins `sendingTimeout` after last successful stamp; with responsive storage this is roughly `sendingTimeout + maxRenewals × renewClaim`. |
379
379
  | `shouldFailOver` | — | Set when your transport can tell "never delivered" from "may have been delivered" — see [Transport fail-over](#transport-fail-over). |
380
380
  | `strictPayload` | `false` | Turn on when anything other than your own app can write to the queue storage. |
381
381
  | `revolvingInterval` | `1536` ms | Lower → faster pickup; higher → less scheduler I/O |
382
382
  | `josk.minRevolvingDelay` / `maxRevolvingDelay` | `512` / `2048` | Lower both → snappier polls, more storage load |
383
- | `josk.zombieTime` | `60000` | **Never below 60s.** After an unclean stop during a claimed scan, JoSk 6.4 preserves the claim until `zombieTime`; use graceful destroy on redeploy. |
383
+ | `josk.zombieTime` | `60000` | **Never below 60s.** After an unclean stop during a claimed scan, JoSk 6.4+ preserves the claim until `zombieTime`; use graceful destroy on redeploy. |
384
384
  | `josk.concurrency` | `Infinity` | Set `1` if scheduler ticks overlap while `iterate` still runs |
385
385
  | `josk.execute` | `'batch'` | Usually leave default; MailTime only registers one JoSk task per instance |
386
386
  | `josk.lockOwnerId` | random | Set in production for observability |
@@ -514,19 +514,19 @@ await mailQueue.sendMail({
514
514
  | `mode` | `'one' \| 'batch'` | `'batch'` | `'batch'` claims every due row per tick; `'one'` claims a single row per tick. |
515
515
  | `concurrency` | `number` | `1` | Parallel SMTPs per instance. CAS blocks concurrent claims for one row. |
516
516
  | `sendingTimeout` | `number` (ms) | `300000` | Window after which a stuck `isSending=true` row becomes eligible again. Must exceed worst-case SMTP roundtrip; values below `120000` log a warning. |
517
- | `renewClaim` | `boolean \| number` (ms) | `sendingTimeout / 3` | Re-stamp `sendingAt` on the claimed row while its SMTP roundtrip runs, so a slow-but-healthy send never loses its lock to a recovery worker. `false` restores the v4 single-stamp behaviour. |
517
+ | `renewClaim` | `boolean \| number` (ms) | `sendingTimeout / 3` (min 1000 ms) | Re-stamp `sendingAt` on the claimed row while its SMTP roundtrip runs, so a slow-but-healthy send never loses its lock to a recovery worker. `false` restores the v4 single-stamp behaviour. |
518
518
  | `maxRenewals` | `number` | `10` | Renewal-attempt budget per send. Recovery begins `sendingTimeout` after last successful stamp; slow renewal writes can extend elapsed time beyond the nominal interval calculation. |
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 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. |
522
+ | `verifyTransports` | `boolean` | `true` | Probe each transport with `transport.verify()` at `ready()`. A failing transport is quarantined (skipped in rotation and fallback) and reported once through `onError(error, null, { transportIndex, phase: 'verify' })`; `ready()` throws only if every transport fails. Quarantined transports are re-probed in the background (60 s, doubling to 15 min) and return to rotation on success. Verdict rules, timeouts and recovery: [transport verification](https://github.com/veliovgroup/mail-time/blob/master/docs/transport-verification.md). |
523
+ | `verifyTimeout` | `number` | `30000` | Milliseconds `ready()` waits per `transport.verify()`; also the budget of each background re-probe. On timeout `ready()` stops waiting and the transport keeps its current health; a late verdict still applies. Values above `2147483647` (including `Infinity`) are clamped; anything else invalid uses `30000`. |
524
524
  | `template` | `string` | `'{{{html}}}'` | Default envelope. |
525
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>`. |
526
526
  | `from` | `string \| (transport, details) => string` | — | Strongly recommended for spam-passing `From:` formatting. `details` is `{ index, from }` — see [Resolving the sender](#resolving-the-sender). |
527
527
  | `debug` | `boolean` | `false` | Verbose logs. |
528
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). |
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). |
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 `details = { 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
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). |
531
531
  | `onSuppressed(task, recipients, summary)` | `function` | — | Terminal suppressed group in policy mode. |
532
532
  | `onRejected(task, recipients, summary)` | `function` | — | Terminal permanently rejected group in policy mode. |
@@ -560,7 +560,7 @@ For deeper JoSk semantics, install the JoSk skill: **`npx skills add veliovgroup
560
560
  - `ping()` → `Promise<{status, code, statusCode, paused?, error?}>`. Pings scheduler then queue; `paused` reflects `isPaused`.
561
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
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
+ - `drain()` → `Promise<{ failedWrites }>`. Resolves once every in-flight SMTP attempt finishes. `failedWrites` counts outcome writes that failed since instance creation; each is also reported through `onError` with `details.phase` `'complete'` or `'checkpoint'`. A non-zero value after `destroy({ drain: true })` means a row may stay `sending` and be re-sent after `sendingTimeout`. Details: [completion writes and drain](https://github.com/veliovgroup/mail-time/blob/master/docs/tuning.md#completion-writes-and-drain).
564
564
  - `pause()` / `resume()` → `boolean`. Server-only reversible backpressure; no-ops on `client` or after `destroy()`. See [Shutdown](#6-shutdown).
565
565
  - `isPaused` → `boolean`. Read-only; always `false` on `client`.
566
566
 
@@ -599,6 +599,7 @@ Upgrade checklists, adapter contract changes, and rollout notes live in the docs
599
599
  | 4.0 | 4.1 | [v4.0 → v4.1](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4-v4.1.md) |
600
600
  | 4.1 | 5.0 | [v4.1 → v5](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4.1-v5.md) |
601
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.1 | 5.2 | [v5.1 → v5.2 recipient policies rollout](https://github.com/veliovgroup/mail-time/blob/master/docs/recipient-policies.md#recovery-and-rollout) |
602
603
  | 5.2 | 5.3 | [v5.2 → v5.3](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v5.2-v5.3.md) |
603
604
 
604
605
  New in v4 (opt-in): `mailTimePreset`, `concurrency`/`mode`, `sendingTimeout`, `drain()`/`pause()`/`resume()`, per-recipient handling, AI skills bundle.
@@ -1,24 +1,25 @@
1
1
  export type MongoCollection = {
2
2
  collectionName?: string | undefined;
3
- createIndex: (keys: object, opts?: object) => Promise<unknown>;
3
+ createIndex: (keys: any, opts?: any) => Promise<unknown>;
4
4
  indexes: () => Promise<{
5
- name: string;
5
+ name?: string;
6
6
  key: Record<string, unknown>;
7
7
  }[]>;
8
8
  dropIndex: (name: string) => Promise<unknown>;
9
- find: (query: object, opts?: object) => unknown;
10
- findOne: (query: object, opts?: object) => Promise<object | null>;
11
- insertOne: (doc: object) => Promise<unknown>;
12
- deleteOne: (query: object) => Promise<{
9
+ find: (query: any, opts?: any) => unknown;
10
+ findOne: (query: any, opts?: any) => Promise<object | null>;
11
+ insertOne: (doc: any) => Promise<unknown>;
12
+ deleteOne: (query: any) => Promise<{
13
13
  deletedCount?: number;
14
14
  }>;
15
- updateOne: (query: object, update: object) => Promise<{
15
+ updateOne: (query: any, update: any) => Promise<{
16
16
  modifiedCount?: number;
17
+ matchedCount?: number;
17
18
  }>;
18
19
  };
19
20
  export type Db = {
20
21
  collection: (name: string) => MongoCollection;
21
- command: (cmd: object) => Promise<{
22
+ command: (cmd: any) => Promise<{
22
23
  ok?: number;
23
24
  }>;
24
25
  };
package/adapters/mongo.js CHANGED
@@ -14,20 +14,20 @@ const DEFAULT_PREFIX = '';
14
14
  /**
15
15
  * @typedef {object} MongoCollection
16
16
  * @property {string} [collectionName]
17
- * @property {(keys: object, opts?: object) => Promise<unknown>} createIndex
18
- * @property {() => Promise<{ name: string, key: Record<string, unknown> }[]>} indexes
17
+ * @property {(keys: any, opts?: any) => Promise<unknown>} createIndex
18
+ * @property {() => Promise<{ name?: string, key: Record<string, unknown> }[]>} indexes
19
19
  * @property {(name: string) => Promise<unknown>} dropIndex
20
- * @property {(query: object, opts?: object) => unknown} find
21
- * @property {(query: object, opts?: object) => Promise<object|null>} findOne
22
- * @property {(doc: object) => Promise<unknown>} insertOne
23
- * @property {(query: object) => Promise<{ deletedCount?: number }>} deleteOne
24
- * @property {(query: object, update: object) => Promise<{ modifiedCount?: number }>} updateOne
20
+ * @property {(query: any, opts?: any) => unknown} find
21
+ * @property {(query: any, opts?: any) => Promise<object|null>} findOne
22
+ * @property {(doc: any) => Promise<unknown>} insertOne
23
+ * @property {(query: any) => Promise<{ deletedCount?: number }>} deleteOne
24
+ * @property {(query: any, update: any) => Promise<{ modifiedCount?: number, matchedCount?: number }>} updateOne
25
25
  */
26
26
 
27
27
  /**
28
28
  * @typedef {object} Db
29
29
  * @property {(name: string) => MongoCollection} collection
30
- * @property {(cmd: object) => Promise<{ ok?: number }>} command
30
+ * @property {(cmd: any) => Promise<{ ok?: number }>} command
31
31
  */
32
32
 
33
33
  /**
@@ -4,6 +4,11 @@ export type PostgresQueryResult = {
4
4
  };
5
5
  export type PostgresClient = {
6
6
  query: (queryText: string, values?: unknown[]) => Promise<PostgresQueryResult>;
7
+ /**
8
+ * - a client whose constructor name ends in `Pool` or that has a numeric `totalCount` (`pg.Pool`) is treated as a pool; setup checks out one connection
9
+ */
10
+ connect?: ((...args: any[]) => any) | undefined;
11
+ totalCount?: number | undefined;
7
12
  };
8
13
  export type PostgresQueueOption = {
9
14
  client: PostgresClient;
@@ -19,6 +19,8 @@ import {
19
19
  /**
20
20
  * @typedef {object} PostgresClient
21
21
  * @property {(queryText: string, values?: unknown[]) => Promise<PostgresQueryResult>} query
22
+ * @property {(...args: any[]) => any} [connect] - a client whose constructor name ends in `Pool` or that has a numeric `totalCount` (`pg.Pool`) is treated as a pool; setup checks out one connection
23
+ * @property {number} [totalCount]
22
24
  */
23
25
 
24
26
  /**
@@ -119,8 +121,18 @@ class PostgresQueue {
119
121
  /** @internal */
120
122
  __applyPrefix(prefix) {
121
123
  this.prefix = prefix;
122
- this.__readyPromise = this.__setup();
123
- this.__readyPromise.catch(() => void 0);
124
+ this.__startSetup();
125
+ }
126
+
127
+ /** @internal */
128
+ __startSetup() {
129
+ const setup = this.__setup();
130
+ this.__readyPromise = setup;
131
+ // A failed setup (database still booting) must not poison every later call; the next
132
+ // ready() retries it. The awaiting caller still sees this attempt's rejection.
133
+ setup.catch(() => {
134
+ if (this.__readyPromise === setup) this.__readyPromise = null;
135
+ });
124
136
  }
125
137
 
126
138
  /** @internal */
@@ -145,16 +157,25 @@ class PostgresQueue {
145
157
  async ready() {
146
158
  this.__ensurePrefix();
147
159
  this.__debug('[ready]');
160
+ if (!this.__readyPromise) this.__startSetup();
148
161
  await this.__readyPromise;
149
162
  }
150
163
 
151
164
  /** @internal */
152
165
  async __setup() {
153
166
  const advisoryLockKey = advisoryLockKeyFor(this.prefix);
154
- await this.client.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
167
+ // `pg_advisory_lock` is session-scoped. Through a `pg.Pool` each `query()` may use a
168
+ // different connection, so lock, DDL and unlock must share one checked-out client;
169
+ // otherwise the lock stays on an idle pooled connection and blocks peer startups.
170
+ const pooled = typeof this.client.connect === 'function'
171
+ && (typeof this.client.totalCount === 'number' || /Pool$/.test(this.client.constructor?.name || ''));
172
+ const conn = pooled ? await this.client.connect() : this.client;
173
+ let locked = false;
155
174
 
156
175
  try {
157
- await this.client.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
176
+ await conn.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
177
+ locked = true;
178
+ await conn.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
158
179
  id BIGSERIAL PRIMARY KEY,
159
180
  prefix TEXT NOT NULL DEFAULT 'default',
160
181
  uuid TEXT NOT NULL,
@@ -175,18 +196,22 @@ class PostgresQueue {
175
196
  created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
176
197
  updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
177
198
  )`);
178
- await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS is_settled BOOLEAN NOT NULL DEFAULT false');
179
- await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS recipient_results JSONB');
180
- await this.client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
199
+ await conn.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS is_settled BOOLEAN NOT NULL DEFAULT false');
200
+ await conn.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS recipient_results JSONB');
201
+ await conn.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
181
202
  ON mail_time_queue (prefix, uuid)`);
182
203
 
183
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_due_v1
204
+ await conn.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_due_v1
184
205
  ON mail_time_queue (prefix, is_settled, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
185
206
 
186
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_pending_to_v1
207
+ await conn.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_pending_to_v1
187
208
  ON mail_time_queue (prefix, to_address, is_settled, is_sent, is_failed, is_cancelled, send_at)`);
188
209
  } finally {
189
- await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
210
+ try {
211
+ if (locked) await conn.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
212
+ } finally {
213
+ if (pooled) conn.release();
214
+ }
190
215
  }
191
216
  }
192
217
 
@@ -6,7 +6,7 @@ export type RedisClient = {
6
6
  ping?: (() => Promise<string>) | undefined;
7
7
  getRandomNode?: (() => unknown) | undefined;
8
8
  nodeClient?: ((...args: any[]) => any) | undefined;
9
- sendCommand?: ((firstKey: string, isReadonly: boolean, args: string[]) => Promise<unknown>) | undefined;
9
+ sendCommand?: ((...args: any[]) => Promise<unknown>) | undefined;
10
10
  scanIterator?: ((options: object) => AsyncIterable<string | string[]>) | undefined;
11
11
  hGet?: ((key: string, field: string) => Promise<string | null | undefined>) | undefined;
12
12
  eval?: ((script: string, options: {
package/adapters/redis.js CHANGED
@@ -19,7 +19,7 @@ import { createHash } from 'crypto';
19
19
  * @property {() => Promise<string>} [ping]
20
20
  * @property {() => unknown} [getRandomNode]
21
21
  * @property {(...args: any[]) => any} [nodeClient]
22
- * @property {(firstKey: string, isReadonly: boolean, args: string[]) => Promise<unknown>} [sendCommand]
22
+ * @property {(...args: any[]) => Promise<unknown>} [sendCommand]
23
23
  * @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
24
24
  * @property {(key: string, field: string) => Promise<string|null|undefined>} [hGet]
25
25
  * @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
@@ -218,6 +218,12 @@ const isNoScriptError = (error) => {
218
218
  return !!error && (error.code === 'NOSCRIPT' || (typeof error.message === 'string' && error.message.includes('NOSCRIPT')));
219
219
  };
220
220
 
221
+ // node-redis 4/5 throw `WatchError` from `exec()` when a watched key changed; that is the
222
+ // expected outcome of a lost CAS race, not a storage failure.
223
+ const isWatchError = (error) => {
224
+ return !!error && (error.name === 'WatchError' || error.constructor?.name === 'WatchError');
225
+ };
226
+
221
227
  const canReleaseLease = (currentTask, updateObj) => {
222
228
  return currentTask
223
229
  && currentTask.tries === updateObj.leaseTries
@@ -611,15 +617,19 @@ class RedisQueue {
611
617
  const taskJSON = JSON.stringify(task);
612
618
 
613
619
  if (typeof this.client.multi === 'function') {
614
- const multi = this.client.multi();
615
- multi.set(letterKey, taskJSON);
616
- multi.set(sendatKey, `${task.sendAt}`);
617
- if (task.to) {
618
- multi.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
619
- PXAT: task.sendAt - 128,
620
- });
621
- }
622
- await multi.exec();
620
+ // EXEC discards every WATCH on the connection, so an unserialized push would turn a
621
+ // concurrent claim's guarded MULTI into an unconditional write (duplicate send).
622
+ await this.__serialize(async () => {
623
+ const multi = this.client.multi();
624
+ multi.set(letterKey, taskJSON);
625
+ multi.set(sendatKey, `${task.sendAt}`);
626
+ if (task.to) {
627
+ multi.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
628
+ PXAT: task.sendAt - 128,
629
+ });
630
+ }
631
+ await multi.exec();
632
+ });
623
633
  return;
624
634
  }
625
635
 
@@ -656,7 +666,7 @@ class RedisQueue {
656
666
  }
657
667
 
658
668
  /** @internal */
659
- async __cancel(uuid) {
669
+ async __cancel(uuid, attempt = 0) {
660
670
  this.__debug('[cancel]', uuid);
661
671
  if (typeof uuid !== 'string') {
662
672
  return false;
@@ -677,7 +687,10 @@ class RedisQueue {
677
687
  const args = keep
678
688
  ? [uuid, JSON.stringify({ isCancelled: true }), 'cancel', `${Date.now()}`, `${this.mailTimeInstance.sendingTimeout || 300000}`, '0', '0', payload, JSON.stringify({ ...task, isCancelled: true })]
679
689
  : [uuid, 'cancel', '0', '0'];
680
- return Number(await this.__runScript(keep ? 'update' : 'remove', { keys, arguments: args })) >= 1;
690
+ const result = Number(await this.__runScript(keep ? 'update' : 'remove', { keys, arguments: args }));
691
+ // -1: the payload changed between hGet and the script (a claim renewal, for example); retry on a fresh snapshot
692
+ if (result === -1 && attempt < 2) return await this.__cancel(uuid, attempt + 1);
693
+ return result >= 1;
681
694
  }
682
695
  if (!atomic) {
683
696
  if (task.recipientResults != null) return false;
@@ -689,7 +702,7 @@ class RedisQueue {
689
702
  multi.del(this.__getKey(uuid, 'sendat'));
690
703
  return (await multi.exec()) !== null;
691
704
  } catch (error) {
692
- logError('[cancel] storage error', error);
705
+ if (!isWatchError(error)) logError('[cancel] storage error', error);
693
706
  return false;
694
707
  } finally {
695
708
  if (!this.useHashTags && atomic) await this.client.unwatch?.();
@@ -746,7 +759,9 @@ class RedisQueue {
746
759
  return false;
747
760
  }
748
761
  try {
762
+ const concatKey = task.to ? this.__getKey(task.to, 'concatletter') : null;
749
763
  await this.client.watch(letterKey);
764
+ if (concatKey) await this.client.watch(concatKey);
750
765
  const taskJSON = await this.client.get(letterKey);
751
766
  if (!taskJSON) {
752
767
  await this.client.unwatch?.();
@@ -764,8 +779,9 @@ class RedisQueue {
764
779
  return false;
765
780
  }
766
781
  const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
767
- if (task.to) {
768
- keysToDelete.push(this.__getKey(task.to, 'concatletter'));
782
+ // The pointer may already name a newer letter folded for the same recipient.
783
+ if (concatKey && (await this.client.get(concatKey)) === task.uuid) {
784
+ keysToDelete.push(concatKey);
769
785
  }
770
786
  const multi = this.client.multi();
771
787
  for (const key of keysToDelete) {
@@ -774,7 +790,7 @@ class RedisQueue {
774
790
  const result = await multi.exec();
775
791
  return result !== null;
776
792
  } catch (opError) {
777
- logError('[remove] [lease] [opError]', opError);
793
+ if (!isWatchError(opError)) logError('[remove] [lease] [opError]', opError);
778
794
  return false;
779
795
  }
780
796
  }
@@ -786,7 +802,10 @@ class RedisQueue {
786
802
 
787
803
  const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
788
804
  if (task.to) {
789
- keysToDelete.push(this.__getKey(task.to, 'concatletter'));
805
+ const concatKey = this.__getKey(task.to, 'concatletter');
806
+ if ((await this.client.get(concatKey)) === task.uuid) {
807
+ keysToDelete.push(concatKey);
808
+ }
790
809
  }
791
810
  await this.client.del(keysToDelete);
792
811
  return true;
@@ -946,7 +965,7 @@ class RedisQueue {
946
965
  }
947
966
  return true;
948
967
  } catch (opError) {
949
- logError('[update] [try/catch] [opError]', opError);
968
+ if (!isWatchError(opError)) logError('[update] [try/catch] [opError]', opError);
950
969
  return false;
951
970
  }
952
971
  }
package/index.cjs CHANGED
@@ -304,20 +304,20 @@ const DEFAULT_PREFIX$2 = '';
304
304
  /**
305
305
  * @typedef {object} MongoCollection
306
306
  * @property {string} [collectionName]
307
- * @property {(keys: object, opts?: object) => Promise<unknown>} createIndex
308
- * @property {() => Promise<{ name: string, key: Record<string, unknown> }[]>} indexes
307
+ * @property {(keys: any, opts?: any) => Promise<unknown>} createIndex
308
+ * @property {() => Promise<{ name?: string, key: Record<string, unknown> }[]>} indexes
309
309
  * @property {(name: string) => Promise<unknown>} dropIndex
310
- * @property {(query: object, opts?: object) => unknown} find
311
- * @property {(query: object, opts?: object) => Promise<object|null>} findOne
312
- * @property {(doc: object) => Promise<unknown>} insertOne
313
- * @property {(query: object) => Promise<{ deletedCount?: number }>} deleteOne
314
- * @property {(query: object, update: object) => Promise<{ modifiedCount?: number }>} updateOne
310
+ * @property {(query: any, opts?: any) => unknown} find
311
+ * @property {(query: any, opts?: any) => Promise<object|null>} findOne
312
+ * @property {(doc: any) => Promise<unknown>} insertOne
313
+ * @property {(query: any) => Promise<{ deletedCount?: number }>} deleteOne
314
+ * @property {(query: any, update: any) => Promise<{ modifiedCount?: number, matchedCount?: number }>} updateOne
315
315
  */
316
316
 
317
317
  /**
318
318
  * @typedef {object} Db
319
319
  * @property {(name: string) => MongoCollection} collection
320
- * @property {(cmd: object) => Promise<{ ok?: number }>} command
320
+ * @property {(cmd: any) => Promise<{ ok?: number }>} command
321
321
  */
322
322
 
323
323
  /**
@@ -748,7 +748,7 @@ class MongoQueue {
748
748
  * @property {() => Promise<string>} [ping]
749
749
  * @property {() => unknown} [getRandomNode]
750
750
  * @property {(...args: any[]) => any} [nodeClient]
751
- * @property {(firstKey: string, isReadonly: boolean, args: string[]) => Promise<unknown>} [sendCommand]
751
+ * @property {(...args: any[]) => Promise<unknown>} [sendCommand]
752
752
  * @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
753
753
  * @property {(key: string, field: string) => Promise<string|null|undefined>} [hGet]
754
754
  * @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
@@ -947,6 +947,12 @@ const isNoScriptError = (error) => {
947
947
  return !!error && (error.code === 'NOSCRIPT' || (typeof error.message === 'string' && error.message.includes('NOSCRIPT')));
948
948
  };
949
949
 
950
+ // node-redis 4/5 throw `WatchError` from `exec()` when a watched key changed; that is the
951
+ // expected outcome of a lost CAS race, not a storage failure.
952
+ const isWatchError = (error) => {
953
+ return !!error && (error.name === 'WatchError' || error.constructor?.name === 'WatchError');
954
+ };
955
+
950
956
  const canReleaseLease = (currentTask, updateObj) => {
951
957
  return currentTask
952
958
  && currentTask.tries === updateObj.leaseTries
@@ -1340,15 +1346,19 @@ class RedisQueue {
1340
1346
  const taskJSON = JSON.stringify(task);
1341
1347
 
1342
1348
  if (typeof this.client.multi === 'function') {
1343
- const multi = this.client.multi();
1344
- multi.set(letterKey, taskJSON);
1345
- multi.set(sendatKey, `${task.sendAt}`);
1346
- if (task.to) {
1347
- multi.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
1348
- PXAT: task.sendAt - 128,
1349
- });
1350
- }
1351
- await multi.exec();
1349
+ // EXEC discards every WATCH on the connection, so an unserialized push would turn a
1350
+ // concurrent claim's guarded MULTI into an unconditional write (duplicate send).
1351
+ await this.__serialize(async () => {
1352
+ const multi = this.client.multi();
1353
+ multi.set(letterKey, taskJSON);
1354
+ multi.set(sendatKey, `${task.sendAt}`);
1355
+ if (task.to) {
1356
+ multi.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
1357
+ PXAT: task.sendAt - 128,
1358
+ });
1359
+ }
1360
+ await multi.exec();
1361
+ });
1352
1362
  return;
1353
1363
  }
1354
1364
 
@@ -1385,7 +1395,7 @@ class RedisQueue {
1385
1395
  }
1386
1396
 
1387
1397
  /** @internal */
1388
- async __cancel(uuid) {
1398
+ async __cancel(uuid, attempt = 0) {
1389
1399
  this.__debug('[cancel]', uuid);
1390
1400
  if (typeof uuid !== 'string') {
1391
1401
  return false;
@@ -1406,7 +1416,10 @@ class RedisQueue {
1406
1416
  const args = keep
1407
1417
  ? [uuid, JSON.stringify({ isCancelled: true }), 'cancel', `${Date.now()}`, `${this.mailTimeInstance.sendingTimeout || 300000}`, '0', '0', payload, JSON.stringify({ ...task, isCancelled: true })]
1408
1418
  : [uuid, 'cancel', '0', '0'];
1409
- return Number(await this.__runScript(keep ? 'update' : 'remove', { keys, arguments: args })) >= 1;
1419
+ const result = Number(await this.__runScript(keep ? 'update' : 'remove', { keys, arguments: args }));
1420
+ // -1: the payload changed between hGet and the script (a claim renewal, for example); retry on a fresh snapshot
1421
+ if (result === -1 && attempt < 2) return await this.__cancel(uuid, attempt + 1);
1422
+ return result >= 1;
1410
1423
  }
1411
1424
  if (!atomic) {
1412
1425
  if (task.recipientResults != null) return false;
@@ -1418,7 +1431,7 @@ class RedisQueue {
1418
1431
  multi.del(this.__getKey(uuid, 'sendat'));
1419
1432
  return (await multi.exec()) !== null;
1420
1433
  } catch (error) {
1421
- logError('[cancel] storage error', error);
1434
+ if (!isWatchError(error)) logError('[cancel] storage error', error);
1422
1435
  return false;
1423
1436
  } finally {
1424
1437
  if (!this.useHashTags && atomic) await this.client.unwatch?.();
@@ -1475,7 +1488,9 @@ class RedisQueue {
1475
1488
  return false;
1476
1489
  }
1477
1490
  try {
1491
+ const concatKey = task.to ? this.__getKey(task.to, 'concatletter') : null;
1478
1492
  await this.client.watch(letterKey);
1493
+ if (concatKey) await this.client.watch(concatKey);
1479
1494
  const taskJSON = await this.client.get(letterKey);
1480
1495
  if (!taskJSON) {
1481
1496
  await this.client.unwatch?.();
@@ -1493,8 +1508,9 @@ class RedisQueue {
1493
1508
  return false;
1494
1509
  }
1495
1510
  const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
1496
- if (task.to) {
1497
- keysToDelete.push(this.__getKey(task.to, 'concatletter'));
1511
+ // The pointer may already name a newer letter folded for the same recipient.
1512
+ if (concatKey && (await this.client.get(concatKey)) === task.uuid) {
1513
+ keysToDelete.push(concatKey);
1498
1514
  }
1499
1515
  const multi = this.client.multi();
1500
1516
  for (const key of keysToDelete) {
@@ -1503,7 +1519,7 @@ class RedisQueue {
1503
1519
  const result = await multi.exec();
1504
1520
  return result !== null;
1505
1521
  } catch (opError) {
1506
- logError('[remove] [lease] [opError]', opError);
1522
+ if (!isWatchError(opError)) logError('[remove] [lease] [opError]', opError);
1507
1523
  return false;
1508
1524
  }
1509
1525
  }
@@ -1515,7 +1531,10 @@ class RedisQueue {
1515
1531
 
1516
1532
  const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
1517
1533
  if (task.to) {
1518
- keysToDelete.push(this.__getKey(task.to, 'concatletter'));
1534
+ const concatKey = this.__getKey(task.to, 'concatletter');
1535
+ if ((await this.client.get(concatKey)) === task.uuid) {
1536
+ keysToDelete.push(concatKey);
1537
+ }
1519
1538
  }
1520
1539
  await this.client.del(keysToDelete);
1521
1540
  return true;
@@ -1675,7 +1694,7 @@ class RedisQueue {
1675
1694
  }
1676
1695
  return true;
1677
1696
  } catch (opError) {
1678
- logError('[update] [try/catch] [opError]', opError);
1697
+ if (!isWatchError(opError)) logError('[update] [try/catch] [opError]', opError);
1679
1698
  return false;
1680
1699
  }
1681
1700
  }
@@ -1716,6 +1735,8 @@ class RedisQueue {
1716
1735
  /**
1717
1736
  * @typedef {object} PostgresClient
1718
1737
  * @property {(queryText: string, values?: unknown[]) => Promise<PostgresQueryResult>} query
1738
+ * @property {(...args: any[]) => any} [connect] - a client whose constructor name ends in `Pool` or that has a numeric `totalCount` (`pg.Pool`) is treated as a pool; setup checks out one connection
1739
+ * @property {number} [totalCount]
1719
1740
  */
1720
1741
 
1721
1742
  /**
@@ -1816,8 +1837,18 @@ class PostgresQueue {
1816
1837
  /** @internal */
1817
1838
  __applyPrefix(prefix) {
1818
1839
  this.prefix = prefix;
1819
- this.__readyPromise = this.__setup();
1820
- this.__readyPromise.catch(() => void 0);
1840
+ this.__startSetup();
1841
+ }
1842
+
1843
+ /** @internal */
1844
+ __startSetup() {
1845
+ const setup = this.__setup();
1846
+ this.__readyPromise = setup;
1847
+ // A failed setup (database still booting) must not poison every later call; the next
1848
+ // ready() retries it. The awaiting caller still sees this attempt's rejection.
1849
+ setup.catch(() => {
1850
+ if (this.__readyPromise === setup) this.__readyPromise = null;
1851
+ });
1821
1852
  }
1822
1853
 
1823
1854
  /** @internal */
@@ -1842,16 +1873,25 @@ class PostgresQueue {
1842
1873
  async ready() {
1843
1874
  this.__ensurePrefix();
1844
1875
  this.__debug('[ready]');
1876
+ if (!this.__readyPromise) this.__startSetup();
1845
1877
  await this.__readyPromise;
1846
1878
  }
1847
1879
 
1848
1880
  /** @internal */
1849
1881
  async __setup() {
1850
1882
  const advisoryLockKey = advisoryLockKeyFor(this.prefix);
1851
- await this.client.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1883
+ // `pg_advisory_lock` is session-scoped. Through a `pg.Pool` each `query()` may use a
1884
+ // different connection, so lock, DDL and unlock must share one checked-out client;
1885
+ // otherwise the lock stays on an idle pooled connection and blocks peer startups.
1886
+ const pooled = typeof this.client.connect === 'function'
1887
+ && (typeof this.client.totalCount === 'number' || /Pool$/.test(this.client.constructor?.name || ''));
1888
+ const conn = pooled ? await this.client.connect() : this.client;
1889
+ let locked = false;
1852
1890
 
1853
1891
  try {
1854
- await this.client.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
1892
+ await conn.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1893
+ locked = true;
1894
+ await conn.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
1855
1895
  id BIGSERIAL PRIMARY KEY,
1856
1896
  prefix TEXT NOT NULL DEFAULT 'default',
1857
1897
  uuid TEXT NOT NULL,
@@ -1872,18 +1912,22 @@ class PostgresQueue {
1872
1912
  created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
1873
1913
  updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
1874
1914
  )`);
1875
- await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS is_settled BOOLEAN NOT NULL DEFAULT false');
1876
- await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS recipient_results JSONB');
1877
- await this.client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
1915
+ await conn.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS is_settled BOOLEAN NOT NULL DEFAULT false');
1916
+ await conn.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS recipient_results JSONB');
1917
+ await conn.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
1878
1918
  ON mail_time_queue (prefix, uuid)`);
1879
1919
 
1880
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_due_v1
1920
+ await conn.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_due_v1
1881
1921
  ON mail_time_queue (prefix, is_settled, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
1882
1922
 
1883
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_pending_to_v1
1923
+ await conn.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_pending_to_v1
1884
1924
  ON mail_time_queue (prefix, to_address, is_settled, is_sent, is_failed, is_cancelled, send_at)`);
1885
1925
  } finally {
1886
- await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1926
+ try {
1927
+ if (locked) await conn.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1928
+ } finally {
1929
+ if (pooled) conn.release();
1930
+ }
1887
1931
  }
1888
1932
  }
1889
1933
 
@@ -2620,7 +2664,11 @@ const validatePolicyMailOptions = (mailOptions) => {
2620
2664
  if (Array.isArray(value)) value.forEach((entry, i) => normalizePolicyAddress(entry, `${label}[${i}]`));
2621
2665
  else normalizePolicyAddress(value, label);
2622
2666
  };
2623
- for (const key of ['from', 'sender', 'replyTo', 'to', 'cc', 'bcc']) check(mailOptions[key], key);
2667
+ const explicit = isPlainObject(mailOptions.envelope) && hasOwnProp(mailOptions.envelope, 'to');
2668
+ // With an explicit `envelope.to`, `preparePolicyEnvelope` treats header recipients as
2669
+ // non-authoritative (group syntax stays legal), so do not reject them here either.
2670
+ const keys = explicit ? ['from', 'sender', 'replyTo'] : ['from', 'sender', 'replyTo', 'to', 'cc', 'bcc'];
2671
+ for (const key of keys) check(mailOptions[key], key);
2624
2672
  if (isPlainObject(mailOptions.envelope)) {
2625
2673
  check(mailOptions.envelope.to, 'envelope.to');
2626
2674
  if (mailOptions.envelope.from !== '') check(mailOptions.envelope.from, 'envelope.from');
@@ -3334,6 +3382,11 @@ class MailTime {
3334
3382
  this.transports.forEach((transport, i) => {
3335
3383
  const from = MailTime.transportFrom(transport);
3336
3384
  if (from !== void 0) normalizePolicyAddress(from, `transports[${i}].from`);
3385
+ // `___compileMailOpts` merges these ahead of `this.from`, so they are sent as-is.
3386
+ for (const key of ['_options', 'options']) {
3387
+ const mailFrom = transport?.[key]?.mailOptions?.from;
3388
+ if (mailFrom !== void 0) normalizePolicyAddress(mailFrom, `transports[${i}].${key}.mailOptions.from`);
3389
+ }
3337
3390
  });
3338
3391
  }
3339
3392
 
@@ -3644,7 +3697,7 @@ class MailTime {
3644
3697
  * @description add email to the queue or append to existing letter if {concatEmails: true}
3645
3698
  * @param {MailTimeMailOptions} opts - email options
3646
3699
  * @returns {Promise<string>} uuid of the email
3647
- * @throws {Error}
3700
+ * @throws {Error} missing `html`/`text`, a `raw` field, or an invalid `to`; with `recipientPolicies`, an unparseable address rejects with `error.code === 'MAIL_TIME_INVALID_ADDRESS'` and `error.field` naming the field (header recipients are skipped when `envelope.to` is explicit)
3648
3701
  */
3649
3702
  async sendMail(opts) {
3650
3703
  opts = (opts && typeof opts === 'object') ? opts : {};
@@ -4730,6 +4783,9 @@ class MailTime {
4730
4783
  if (this.__isDestroyed || this.__isPaused) {
4731
4784
  return;
4732
4785
  }
4786
+ // Under `strategy: 'backup'` a quarantined primary is never selected for a send, so the
4787
+ // send path alone would never re-probe it. Sweep here so recovery does not depend on row flow.
4788
+ for (const index of this.__unhealthyTransports) this.___maybeReprobe(index);
4733
4789
  const limit = this.mode === 'one' ? 1 : Infinity;
4734
4790
  this.__schedulerScans++;
4735
4791
  try {
@@ -4878,12 +4934,8 @@ class MailTime {
4878
4934
  }
4879
4935
  if (quarantined) {
4880
4936
  const st = this.__reprobe.get(index);
4881
- if (st) {
4882
- st.inFlight = false;
4883
- st.count++;
4884
- st.nextProbeAt = Date.now() + Math.min(REPROBE_BASE_MS * Math.pow(2, st.count), REPROBE_MAX_MS);
4885
- this.__debug(`[private reprobe] transport #${index} failed, next probe at ${new Date(st.nextProbeAt)}`);
4886
- }
4937
+ // A slot already released by the timeout path keeps its backoff; do not double it twice.
4938
+ if (st && st.inFlight) this.___backoffReprobe(index, st, 'failed');
4887
4939
  return;
4888
4940
  }
4889
4941
  this.__unhealthyTransports.add(index);
@@ -4899,7 +4951,7 @@ class MailTime {
4899
4951
  * @internal
4900
4952
  * @memberOf MailTime
4901
4953
  * @name ___maybeReprobe
4902
- * @description Start one background `verify()` probe for a quarantined transport when its backoff has elapsed and no probe is in flight. Never awaited by the send path. Backoff starts at 60 s, doubles per failed probe and caps at 15 min. A probe that times out stays in flight until its verdict arrives, so at most one `verify()` is outstanding per transport. Failures only reach `__debug`. No probes for `type: 'client'`, `verifyTransports: false`, or after `destroy()`.
4954
+ * @description Start one background `verify()` probe for a quarantined transport when its backoff has elapsed and no probe is in flight. Never awaited by the send path. Backoff starts at 60 s, doubles per failed or timed-out probe and caps at 15 min. A probe that reaches `verifyTimeout` without a verdict releases the slot (one `verify()` may then overlap with the hung one); its late verdict still applies unless a newer probe has started. Failures only reach `__debug`. No probes for `type: 'client'`, `verifyTransports: false`, or after `destroy()`.
4903
4955
  * @param {number} index
4904
4956
  * @returns {void}
4905
4957
  */
@@ -4914,9 +4966,33 @@ class MailTime {
4914
4966
  }
4915
4967
  st.inFlight = true;
4916
4968
  this.__debug(`[private reprobe] transport #${index}, attempt after ${st.count} failure(s)`);
4917
- // The verdict, not the timeout, releases `inFlight` (see ___applyVerdict), so a hung
4918
- // verify() never overlaps with a new probe on the same transport.
4919
- this.___verifyOne(transport, index);
4969
+ // A verdict releases `inFlight` (see ___applyVerdict). A probe that reaches `verifyTimeout`
4970
+ // without one counts as a failed attempt so a verify() that never settles cannot pin the
4971
+ // transport in quarantine; its late verdict still applies unless a newer probe has started.
4972
+ this.___verifyOne(transport, index).then((outcome) => {
4973
+ // A live verdict always clears `inFlight` in ___applyVerdict. If it is still set, the probe
4974
+ // timed out or its verdict was ignored (superseded generation); either way free the slot.
4975
+ if (st.inFlight && this.__reprobe.get(index) === st) {
4976
+ this.___backoffReprobe(index, st, outcome === 'timeout' ? 'timed out' : 'superseded');
4977
+ }
4978
+ });
4979
+ }
4980
+
4981
+ /**
4982
+ * @internal
4983
+ * @memberOf MailTime
4984
+ * @name ___backoffReprobe
4985
+ * @description Release a re-probe slot after a failed or timed-out probe and double its backoff (cap 15 min).
4986
+ * @param {number} index
4987
+ * @param {{ count: number, nextProbeAt: number, inFlight: boolean }} st
4988
+ * @param {string} why
4989
+ * @returns {void}
4990
+ */
4991
+ ___backoffReprobe(index, st, why) {
4992
+ st.inFlight = false;
4993
+ st.count++;
4994
+ st.nextProbeAt = Date.now() + Math.min(REPROBE_BASE_MS * Math.pow(2, st.count), REPROBE_MAX_MS);
4995
+ this.__debug(`[private reprobe] transport #${index} ${why}, next probe at ${new Date(st.nextProbeAt)}`);
4920
4996
  }
4921
4997
 
4922
4998
  /**
package/index.d.cts CHANGED
@@ -400,7 +400,7 @@ export class MailTime {
400
400
  * @description add email to the queue or append to existing letter if {concatEmails: true}
401
401
  * @param {MailTimeMailOptions} opts - email options
402
402
  * @returns {Promise<string>} uuid of the email
403
- * @throws {Error}
403
+ * @throws {Error} missing `html`/`text`, a `raw` field, or an invalid `to`; with `recipientPolicies`, an unparseable address rejects with `error.code === 'MAIL_TIME_INVALID_ADDRESS'` and `error.field` naming the field (header recipients are skipped when `envelope.to` is explicit)
404
404
  */
405
405
  sendMail(opts: MailTimeMailOptions): Promise<string>;
406
406
  /**
package/index.d.ts CHANGED
@@ -400,7 +400,7 @@ export class MailTime {
400
400
  * @description add email to the queue or append to existing letter if {concatEmails: true}
401
401
  * @param {MailTimeMailOptions} opts - email options
402
402
  * @returns {Promise<string>} uuid of the email
403
- * @throws {Error}
403
+ * @throws {Error} missing `html`/`text`, a `raw` field, or an invalid `to`; with `recipientPolicies`, an unparseable address rejects with `error.code === 'MAIL_TIME_INVALID_ADDRESS'` and `error.field` naming the field (header recipients are skipped when `envelope.to` is explicit)
404
404
  */
405
405
  sendMail(opts: MailTimeMailOptions): Promise<string>;
406
406
  /**
package/index.js CHANGED
@@ -428,6 +428,11 @@ class MailTime {
428
428
  this.transports.forEach((transport, i) => {
429
429
  const from = MailTime.transportFrom(transport);
430
430
  if (from !== void 0) normalizePolicyAddress(from, `transports[${i}].from`);
431
+ // `___compileMailOpts` merges these ahead of `this.from`, so they are sent as-is.
432
+ for (const key of ['_options', 'options']) {
433
+ const mailFrom = transport?.[key]?.mailOptions?.from;
434
+ if (mailFrom !== void 0) normalizePolicyAddress(mailFrom, `transports[${i}].${key}.mailOptions.from`);
435
+ }
431
436
  });
432
437
  }
433
438
 
@@ -738,7 +743,7 @@ class MailTime {
738
743
  * @description add email to the queue or append to existing letter if {concatEmails: true}
739
744
  * @param {MailTimeMailOptions} opts - email options
740
745
  * @returns {Promise<string>} uuid of the email
741
- * @throws {Error}
746
+ * @throws {Error} missing `html`/`text`, a `raw` field, or an invalid `to`; with `recipientPolicies`, an unparseable address rejects with `error.code === 'MAIL_TIME_INVALID_ADDRESS'` and `error.field` naming the field (header recipients are skipped when `envelope.to` is explicit)
742
747
  */
743
748
  async sendMail(opts) {
744
749
  opts = (opts && typeof opts === 'object') ? opts : {};
@@ -1824,6 +1829,9 @@ class MailTime {
1824
1829
  if (this.__isDestroyed || this.__isPaused) {
1825
1830
  return;
1826
1831
  }
1832
+ // Under `strategy: 'backup'` a quarantined primary is never selected for a send, so the
1833
+ // send path alone would never re-probe it. Sweep here so recovery does not depend on row flow.
1834
+ for (const index of this.__unhealthyTransports) this.___maybeReprobe(index);
1827
1835
  const limit = this.mode === 'one' ? 1 : Infinity;
1828
1836
  this.__schedulerScans++;
1829
1837
  try {
@@ -1972,12 +1980,8 @@ class MailTime {
1972
1980
  }
1973
1981
  if (quarantined) {
1974
1982
  const st = this.__reprobe.get(index);
1975
- if (st) {
1976
- st.inFlight = false;
1977
- st.count++;
1978
- st.nextProbeAt = Date.now() + Math.min(REPROBE_BASE_MS * Math.pow(2, st.count), REPROBE_MAX_MS);
1979
- this.__debug(`[private reprobe] transport #${index} failed, next probe at ${new Date(st.nextProbeAt)}`);
1980
- }
1983
+ // A slot already released by the timeout path keeps its backoff; do not double it twice.
1984
+ if (st && st.inFlight) this.___backoffReprobe(index, st, 'failed');
1981
1985
  return;
1982
1986
  }
1983
1987
  this.__unhealthyTransports.add(index);
@@ -1993,7 +1997,7 @@ class MailTime {
1993
1997
  * @internal
1994
1998
  * @memberOf MailTime
1995
1999
  * @name ___maybeReprobe
1996
- * @description Start one background `verify()` probe for a quarantined transport when its backoff has elapsed and no probe is in flight. Never awaited by the send path. Backoff starts at 60 s, doubles per failed probe and caps at 15 min. A probe that times out stays in flight until its verdict arrives, so at most one `verify()` is outstanding per transport. Failures only reach `__debug`. No probes for `type: 'client'`, `verifyTransports: false`, or after `destroy()`.
2000
+ * @description Start one background `verify()` probe for a quarantined transport when its backoff has elapsed and no probe is in flight. Never awaited by the send path. Backoff starts at 60 s, doubles per failed or timed-out probe and caps at 15 min. A probe that reaches `verifyTimeout` without a verdict releases the slot (one `verify()` may then overlap with the hung one); its late verdict still applies unless a newer probe has started. Failures only reach `__debug`. No probes for `type: 'client'`, `verifyTransports: false`, or after `destroy()`.
1997
2001
  * @param {number} index
1998
2002
  * @returns {void}
1999
2003
  */
@@ -2008,9 +2012,33 @@ class MailTime {
2008
2012
  }
2009
2013
  st.inFlight = true;
2010
2014
  this.__debug(`[private reprobe] transport #${index}, attempt after ${st.count} failure(s)`);
2011
- // The verdict, not the timeout, releases `inFlight` (see ___applyVerdict), so a hung
2012
- // verify() never overlaps with a new probe on the same transport.
2013
- this.___verifyOne(transport, index);
2015
+ // A verdict releases `inFlight` (see ___applyVerdict). A probe that reaches `verifyTimeout`
2016
+ // without one counts as a failed attempt so a verify() that never settles cannot pin the
2017
+ // transport in quarantine; its late verdict still applies unless a newer probe has started.
2018
+ this.___verifyOne(transport, index).then((outcome) => {
2019
+ // A live verdict always clears `inFlight` in ___applyVerdict. If it is still set, the probe
2020
+ // timed out or its verdict was ignored (superseded generation); either way free the slot.
2021
+ if (st.inFlight && this.__reprobe.get(index) === st) {
2022
+ this.___backoffReprobe(index, st, outcome === 'timeout' ? 'timed out' : 'superseded');
2023
+ }
2024
+ });
2025
+ }
2026
+
2027
+ /**
2028
+ * @internal
2029
+ * @memberOf MailTime
2030
+ * @name ___backoffReprobe
2031
+ * @description Release a re-probe slot after a failed or timed-out probe and double its backoff (cap 15 min).
2032
+ * @param {number} index
2033
+ * @param {{ count: number, nextProbeAt: number, inFlight: boolean }} st
2034
+ * @param {string} why
2035
+ * @returns {void}
2036
+ */
2037
+ ___backoffReprobe(index, st, why) {
2038
+ st.inFlight = false;
2039
+ st.count++;
2040
+ st.nextProbeAt = Date.now() + Math.min(REPROBE_BASE_MS * Math.pow(2, st.count), REPROBE_MAX_MS);
2041
+ this.__debug(`[private reprobe] transport #${index} ${why}, next probe at ${new Date(st.nextProbeAt)}`);
2014
2042
  }
2015
2043
 
2016
2044
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mail-time",
3
- "version": "5.3.0",
3
+ "version": "5.3.1",
4
4
  "description": "📮 Email queue extending nodemailer with multi-SMTP transports and horizontally scaled apps support",
5
5
  "main": "index.js",
6
6
  "types": "./index.d.ts",
@@ -39,24 +39,40 @@
39
39
  "import": {
40
40
  "types": "./presets.d.ts",
41
41
  "default": "./presets.js"
42
+ },
43
+ "require": {
44
+ "types": "./index.d.cts",
45
+ "default": "./index.cjs"
42
46
  }
43
47
  },
44
48
  "./adapters/mongo": {
45
49
  "import": {
46
50
  "types": "./adapters/mongo.d.ts",
47
51
  "default": "./adapters/mongo.js"
52
+ },
53
+ "require": {
54
+ "types": "./index.d.cts",
55
+ "default": "./index.cjs"
48
56
  }
49
57
  },
50
58
  "./adapters/redis": {
51
59
  "import": {
52
60
  "types": "./adapters/redis.d.ts",
53
61
  "default": "./adapters/redis.js"
62
+ },
63
+ "require": {
64
+ "types": "./index.d.cts",
65
+ "default": "./index.cjs"
54
66
  }
55
67
  },
56
68
  "./adapters/postgres": {
57
69
  "import": {
58
70
  "types": "./adapters/postgres.d.ts",
59
71
  "default": "./adapters/postgres.js"
72
+ },
73
+ "require": {
74
+ "types": "./index.d.cts",
75
+ "default": "./index.cjs"
60
76
  }
61
77
  },
62
78
  "./package.json": "./package.json"
@@ -75,7 +91,9 @@
75
91
  "test:redis": "mocha ./test/npm-redis-redis.js",
76
92
  "test:mongo": "mocha ./test/npm-mongo-mongo.js",
77
93
  "test:postgres": "mocha ./test/npm-postgres.js",
78
- "test:bun": "bun test ./test/jest"
94
+ "test:bun": "bun test ./test/jest",
95
+ "test:pack": "node ./scripts/check-package.mjs",
96
+ "test:mocha:local": "REDIS_URL=redis://127.0.0.1:6379 MONGO_URL=mongodb://127.0.0.1:27017/npm-mail-time-test PG_URL=postgres://postgres:postgres@127.0.0.1:5432/postgres npm run test:mocha"
79
97
  },
80
98
  "keywords": [
81
99
  "backup",
@@ -154,6 +172,7 @@
154
172
  "devDependencies": {
155
173
  "@jest/globals": "^30.4.1",
156
174
  "@types/node": "^25.9.0",
175
+ "@types/pg": "^8.23.1",
157
176
  "acorn": "^8.18.0",
158
177
  "chai": "^6.2.2",
159
178
  "jest": "^30.4.2",
@@ -176,7 +176,11 @@ const validatePolicyMailOptions = (mailOptions) => {
176
176
  if (Array.isArray(value)) value.forEach((entry, i) => normalizePolicyAddress(entry, `${label}[${i}]`));
177
177
  else normalizePolicyAddress(value, label);
178
178
  };
179
- for (const key of ['from', 'sender', 'replyTo', 'to', 'cc', 'bcc']) check(mailOptions[key], key);
179
+ const explicit = isPlainObject(mailOptions.envelope) && hasOwnProp(mailOptions.envelope, 'to');
180
+ // With an explicit `envelope.to`, `preparePolicyEnvelope` treats header recipients as
181
+ // non-authoritative (group syntax stays legal), so do not reject them here either.
182
+ const keys = explicit ? ['from', 'sender', 'replyTo'] : ['from', 'sender', 'replyTo', 'to', 'cc', 'bcc'];
183
+ for (const key of keys) check(mailOptions[key], key);
180
184
  if (isPlainObject(mailOptions.envelope)) {
181
185
  check(mailOptions.envelope.to, 'envelope.to');
182
186
  if (mailOptions.envelope.from !== '') check(mailOptions.envelope.from, 'envelope.from');