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 +9 -8
- package/adapters/mongo.d.ts +9 -8
- package/adapters/mongo.js +8 -8
- package/adapters/postgres.d.ts +5 -0
- package/adapters/postgres.js +35 -10
- package/adapters/redis.d.ts +1 -1
- package/adapters/redis.js +37 -18
- package/index.cjs +124 -48
- package/index.d.cts +1 -1
- package/index.d.ts +1 -1
- package/index.js +39 -11
- package/package.json +21 -2
- package/recipient-policy.js +5 -1
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`
|
|
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
|
|
523
|
-
| `verifyTimeout` | `number` | `30000` | Milliseconds `ready()` waits
|
|
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 `
|
|
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
|
|
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.
|
package/adapters/mongo.d.ts
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
export type MongoCollection = {
|
|
2
2
|
collectionName?: string | undefined;
|
|
3
|
-
createIndex: (keys:
|
|
3
|
+
createIndex: (keys: any, opts?: any) => Promise<unknown>;
|
|
4
4
|
indexes: () => Promise<{
|
|
5
|
-
name
|
|
5
|
+
name?: string;
|
|
6
6
|
key: Record<string, unknown>;
|
|
7
7
|
}[]>;
|
|
8
8
|
dropIndex: (name: string) => Promise<unknown>;
|
|
9
|
-
find: (query:
|
|
10
|
-
findOne: (query:
|
|
11
|
-
insertOne: (doc:
|
|
12
|
-
deleteOne: (query:
|
|
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:
|
|
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:
|
|
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:
|
|
18
|
-
* @property {() => Promise<{ name
|
|
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:
|
|
21
|
-
* @property {(query:
|
|
22
|
-
* @property {(doc:
|
|
23
|
-
* @property {(query:
|
|
24
|
-
* @property {(query:
|
|
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:
|
|
30
|
+
* @property {(cmd: any) => Promise<{ ok?: number }>} command
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
/**
|
package/adapters/postgres.d.ts
CHANGED
|
@@ -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;
|
package/adapters/postgres.js
CHANGED
|
@@ -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.
|
|
123
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
179
|
-
await
|
|
180
|
-
await
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/adapters/redis.d.ts
CHANGED
|
@@ -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?: ((
|
|
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 {(
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
multi.set(
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
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
|
-
|
|
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:
|
|
308
|
-
* @property {() => Promise<{ name
|
|
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:
|
|
311
|
-
* @property {(query:
|
|
312
|
-
* @property {(doc:
|
|
313
|
-
* @property {(query:
|
|
314
|
-
* @property {(query:
|
|
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:
|
|
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 {(
|
|
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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
multi.set(
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
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.
|
|
1820
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1876
|
-
await
|
|
1877
|
-
await
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4882
|
-
|
|
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
|
|
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
|
-
//
|
|
4918
|
-
//
|
|
4919
|
-
|
|
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
|
-
|
|
1976
|
-
|
|
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
|
|
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
|
-
//
|
|
2012
|
-
//
|
|
2013
|
-
|
|
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.
|
|
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",
|
package/recipient-policy.js
CHANGED
|
@@ -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
|
-
|
|
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');
|