mail-time 5.2.1 → 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 +24 -12
- 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 +580 -141
- package/index.d.cts +20 -76
- package/index.d.ts +20 -76
- package/index.js +307 -79
- package/package.json +24 -5
- package/recipient-policy-lease.js +40 -8
- package/recipient-policy.js +155 -20
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
|
|
|
@@ -2446,29 +2490,113 @@ const mailTimePreset = (name, overrides) => {
|
|
|
2446
2490
|
|
|
2447
2491
|
const policyError = (message) => new Error(`[mail-time] [recipientPolicies] ${message}`);
|
|
2448
2492
|
const SIMPLE_MAILBOX = /^[^\s<>,;:"()\\\[\]@]+@[^\s<>,;:"()\\\[\]@]+$/u;
|
|
2493
|
+
// Unquoted display-name characters that are auto-quoted in the sent header; `,` `;` `:` `<` `>` stay rejected.
|
|
2494
|
+
const QUOTABLE_SPECIALS = /[()[\]@\\]/u;
|
|
2495
|
+
const UNSAFE_CHARS = /[\r\n\u0000]/u;
|
|
2496
|
+
|
|
2497
|
+
/**
|
|
2498
|
+
* Error for an address that cannot be parsed into exactly one mailbox. The message names
|
|
2499
|
+
* the field and the rule that failed, never the address or display name itself.
|
|
2500
|
+
*/
|
|
2501
|
+
const addressError = (field, reason) => {
|
|
2502
|
+
const error = policyError(`\`${field}\` ${reason}`);
|
|
2503
|
+
error.code = 'MAIL_TIME_INVALID_ADDRESS';
|
|
2504
|
+
error.field = field;
|
|
2505
|
+
return error;
|
|
2506
|
+
};
|
|
2507
|
+
|
|
2508
|
+
const isAddressError = (error) => error?.code === 'MAIL_TIME_INVALID_ADDRESS';
|
|
2449
2509
|
|
|
2450
|
-
const
|
|
2451
|
-
const
|
|
2452
|
-
if (
|
|
2453
|
-
|
|
2510
|
+
const toMailbox = (address, field) => {
|
|
2511
|
+
const trimmed = address.trim();
|
|
2512
|
+
if (!SIMPLE_MAILBOX.test(trimmed)) throw addressError(field, 'must contain one address such as user@example.com; quoted local parts are not supported');
|
|
2513
|
+
return trimmed.toLowerCase();
|
|
2514
|
+
};
|
|
2515
|
+
|
|
2516
|
+
/**
|
|
2517
|
+
* Parse one Nodemailer mailbox string: `addr`, `<addr>`, or `display name <addr>`, where the
|
|
2518
|
+
* display name is any mix of atoms and quoted strings with backslash escapes. Unquoted
|
|
2519
|
+
* `( ) [ ] @ \\` in a display name set `quote`, so the sent header carries the name quoted.
|
|
2520
|
+
* Groups, comma-separated lists, and text after `>` are rejected rather than guessed at.
|
|
2521
|
+
* @returns {{ name: string, address: string, quote: boolean }} `address` keeps its case.
|
|
2522
|
+
*/
|
|
2523
|
+
const parseMailboxString = (input, field) => {
|
|
2524
|
+
let display = '';
|
|
2525
|
+
let name = '';
|
|
2526
|
+
let angle = null;
|
|
2527
|
+
let quoted = false;
|
|
2528
|
+
for (let i = 0; i < input.length; i++) {
|
|
2529
|
+
const char = input[i];
|
|
2530
|
+
if (quoted) {
|
|
2531
|
+
if (char === '\\') {
|
|
2532
|
+
if (++i >= input.length) break;
|
|
2533
|
+
name += input[i];
|
|
2534
|
+
} else if (char === '"') {
|
|
2535
|
+
quoted = false;
|
|
2536
|
+
} else {
|
|
2537
|
+
name += char;
|
|
2538
|
+
}
|
|
2539
|
+
continue;
|
|
2540
|
+
}
|
|
2541
|
+
if (char === '"') {
|
|
2542
|
+
quoted = true;
|
|
2543
|
+
} else if (char === '<') {
|
|
2544
|
+
const close = input.indexOf('>', i + 1);
|
|
2545
|
+
if (close === -1) throw addressError(field, 'has an unclosed angle bracket');
|
|
2546
|
+
angle = input.slice(i + 1, close);
|
|
2547
|
+
if (input.slice(close + 1).trim()) throw addressError(field, 'must end after the angle-bracket address; use an array for multiple recipients');
|
|
2548
|
+
break;
|
|
2549
|
+
} else if (char === ',') {
|
|
2550
|
+
throw addressError(field, 'must contain one mailbox; use an array for multiple recipients or quote a display name that contains a comma');
|
|
2551
|
+
} else if (char === ';' || char === ':') {
|
|
2552
|
+
throw addressError(field, 'must not use group syntax');
|
|
2553
|
+
} else if (char === '>') {
|
|
2554
|
+
throw addressError(field, 'has an unquoted `>` in its display name; wrap the name in double quotes');
|
|
2555
|
+
} else {
|
|
2556
|
+
display += char;
|
|
2557
|
+
name += char;
|
|
2558
|
+
}
|
|
2454
2559
|
}
|
|
2455
|
-
|
|
2456
|
-
if (
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
address
|
|
2560
|
+
if (quoted) throw addressError(field, 'has an unterminated quoted display name');
|
|
2561
|
+
if (angle === null) {
|
|
2562
|
+
if (display !== input) throw addressError(field, 'must contain one address such as user@example.com; quoted local parts are not supported');
|
|
2563
|
+
toMailbox(input, field);
|
|
2564
|
+
return { name: '', address: input, quote: false };
|
|
2460
2565
|
}
|
|
2461
|
-
|
|
2462
|
-
return address.
|
|
2566
|
+
toMailbox(angle, field);
|
|
2567
|
+
return { name: name.replace(/\s+/gu, ' ').trim(), address: angle.trim(), quote: QUOTABLE_SPECIALS.test(display) };
|
|
2568
|
+
};
|
|
2569
|
+
|
|
2570
|
+
/**
|
|
2571
|
+
* Normalize exactly one mailbox (string or `{ name?, address }`) to its lowercase address.
|
|
2572
|
+
* @param {unknown} value
|
|
2573
|
+
* @param {string} [field] - Field label used in errors, e.g. `to[1]` or `envelope.from`.
|
|
2574
|
+
* @returns {string}
|
|
2575
|
+
*/
|
|
2576
|
+
const normalizePolicyAddress = (value, field = 'address') => {
|
|
2577
|
+
if (typeof value === 'string') {
|
|
2578
|
+
if (UNSAFE_CHARS.test(value)) throw addressError(field, 'must not contain line breaks or NUL characters');
|
|
2579
|
+
if (!value.trim()) throw addressError(field, 'must not be empty');
|
|
2580
|
+
return parseMailboxString(value.trim(), field).address.toLowerCase();
|
|
2581
|
+
}
|
|
2582
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw addressError(field, 'must be an address string or a { name, address } object');
|
|
2583
|
+
if (typeof value.address !== 'string') throw addressError(field, 'object requires a string `address`');
|
|
2584
|
+
if (value.name !== void 0 && typeof value.name !== 'string') throw addressError(field, 'object `name` must be a string');
|
|
2585
|
+
if (UNSAFE_CHARS.test(value.address) || (value.name && UNSAFE_CHARS.test(value.name))) {
|
|
2586
|
+
throw addressError(field, 'must not contain line breaks or NUL characters');
|
|
2587
|
+
}
|
|
2588
|
+
return toMailbox(value.address, field);
|
|
2463
2589
|
};
|
|
2464
2590
|
|
|
2465
2591
|
const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
2466
2592
|
const recipients = new Map();
|
|
2467
2593
|
const explicit = compiled.envelope && hasOwnProp(compiled.envelope, 'to');
|
|
2468
|
-
const add = (value, source, authoritative) => {
|
|
2469
|
-
|
|
2594
|
+
const add = (value, source, authoritative, label = source) => {
|
|
2595
|
+
const list = Array.isArray(value);
|
|
2596
|
+
const entries = list ? value : [value];
|
|
2597
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2470
2598
|
let address;
|
|
2471
|
-
try { address = normalizePolicyAddress(
|
|
2599
|
+
try { address = normalizePolicyAddress(entries[i], list ? `${label}[${i}]` : label); }
|
|
2472
2600
|
catch (error) { if (authoritative) throw error; else continue; }
|
|
2473
2601
|
if (!recipients.has(address)) {
|
|
2474
2602
|
if (!authoritative) continue;
|
|
@@ -2478,7 +2606,7 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
|
2478
2606
|
if (!sources.includes(source)) sources.push(source);
|
|
2479
2607
|
}
|
|
2480
2608
|
};
|
|
2481
|
-
if (explicit) add(compiled.envelope.to, 'envelope', true);
|
|
2609
|
+
if (explicit) add(compiled.envelope.to, 'envelope', true, 'envelope.to');
|
|
2482
2610
|
for (const source of ['to', 'cc', 'bcc']) {
|
|
2483
2611
|
if (hasOwnProp(compiled, source) && compiled[source] !== void 0) add(compiled[source], source, !explicit);
|
|
2484
2612
|
}
|
|
@@ -2488,14 +2616,65 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
|
2488
2616
|
}
|
|
2489
2617
|
const envelope = { to: [...recipients.keys()] };
|
|
2490
2618
|
if (compiled.envelope && hasOwnProp(compiled.envelope, 'from')) {
|
|
2491
|
-
envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from);
|
|
2619
|
+
envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from, 'envelope.from');
|
|
2492
2620
|
} else {
|
|
2493
|
-
|
|
2494
|
-
|
|
2621
|
+
// Same precedence as Nodemailer's getEnvelope(): From, then Sender, then Reply-To.
|
|
2622
|
+
const field = ['from', 'sender', 'replyTo'].find((key) => compiled[key]);
|
|
2623
|
+
if (field) envelope.from = normalizePolicyAddress(compiled[field], field);
|
|
2495
2624
|
}
|
|
2496
2625
|
return { envelope, recipients: [...recipients.values()] };
|
|
2497
2626
|
};
|
|
2498
2627
|
|
|
2628
|
+
/**
|
|
2629
|
+
* Return `compiled` with every header mailbox string whose display name needs quoting replaced by
|
|
2630
|
+
* `{ name, address }`, so Nodemailer renders the same mailbox the policy checked. Other entries,
|
|
2631
|
+
* including ones that do not parse, are left as they are. The input is never mutated.
|
|
2632
|
+
*/
|
|
2633
|
+
const rewritePolicyHeaders = (compiled) => {
|
|
2634
|
+
let result = compiled;
|
|
2635
|
+
const rewrite = (value) => {
|
|
2636
|
+
if (typeof value !== 'string' || UNSAFE_CHARS.test(value)) return value;
|
|
2637
|
+
try {
|
|
2638
|
+
const { name, address, quote } = parseMailboxString(value.trim(), 'header');
|
|
2639
|
+
return quote ? { name, address } : value;
|
|
2640
|
+
} catch { return value; }
|
|
2641
|
+
};
|
|
2642
|
+
for (const key of ['from', 'sender', 'replyTo', 'to', 'cc', 'bcc']) {
|
|
2643
|
+
if (!hasOwnProp(compiled, key)) continue;
|
|
2644
|
+
const value = compiled[key];
|
|
2645
|
+
const next = Array.isArray(value) ? value.map(rewrite) : rewrite(value);
|
|
2646
|
+
if (Array.isArray(value) ? next.some((entry, i) => entry !== value[i]) : next !== value) {
|
|
2647
|
+
if (result === compiled) result = { ...compiled };
|
|
2648
|
+
result[key] = next;
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
return result;
|
|
2652
|
+
};
|
|
2653
|
+
|
|
2654
|
+
/**
|
|
2655
|
+
* Validate every address field present in a letter with the policy parser, so an unparseable
|
|
2656
|
+
* address is rejected at `sendMail()` instead of after a queue round trip. Absent fields are
|
|
2657
|
+
* skipped. Throws the same `MAIL_TIME_INVALID_ADDRESS` error the send path would.
|
|
2658
|
+
* @param {object} mailOptions
|
|
2659
|
+
* @returns {void}
|
|
2660
|
+
*/
|
|
2661
|
+
const validatePolicyMailOptions = (mailOptions) => {
|
|
2662
|
+
const check = (value, label) => {
|
|
2663
|
+
if (value === void 0) return;
|
|
2664
|
+
if (Array.isArray(value)) value.forEach((entry, i) => normalizePolicyAddress(entry, `${label}[${i}]`));
|
|
2665
|
+
else normalizePolicyAddress(value, label);
|
|
2666
|
+
};
|
|
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);
|
|
2672
|
+
if (isPlainObject(mailOptions.envelope)) {
|
|
2673
|
+
check(mailOptions.envelope.to, 'envelope.to');
|
|
2674
|
+
if (mailOptions.envelope.from !== '') check(mailOptions.envelope.from, 'envelope.from');
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
|
|
2499
2678
|
const validateRecipientPolicies = (value, queue) => {
|
|
2500
2679
|
if (value === void 0) return null;
|
|
2501
2680
|
if (!Array.isArray(value) || !value.length) throw policyError('recipientPolicies must be a nonempty array');
|
|
@@ -2532,7 +2711,7 @@ const validatePolicyResult = (raw, hook, context) => {
|
|
|
2532
2711
|
const decisions = new Map();
|
|
2533
2712
|
for (const item of raw.decisions) {
|
|
2534
2713
|
if (!isPlainObject(item)) throw policyError('decision must be an object');
|
|
2535
|
-
const address = normalizePolicyAddress(item.address);
|
|
2714
|
+
const address = normalizePolicyAddress(item.address, 'decision.address');
|
|
2536
2715
|
if (!batch.has(address)) throw policyError('decision address is outside the input batch');
|
|
2537
2716
|
const allowed = hook === 'beforeSend' ? item.status === 'suppressed' : (item.status === 'retry' || item.status === 'rejected');
|
|
2538
2717
|
if (!allowed) throw policyError('invalid decision status for this phase');
|
|
@@ -2666,7 +2845,11 @@ const normalizeRejections = (error, info, transport) => {
|
|
|
2666
2845
|
};
|
|
2667
2846
|
|
|
2668
2847
|
class RecipientPolicyLease {
|
|
2669
|
-
constructor({ task, queue, interval, maxRenewals, shouldAbort, report }) {
|
|
2848
|
+
constructor({ task, queue, interval, maxRenewals, sendingTimeout, shouldAbort, report }) {
|
|
2849
|
+
if (typeof sendingTimeout !== 'number' || !(sendingTimeout > 0)) throw new TypeError('[mail-time] [recipientPolicies] RecipientPolicyLease requires a positive sendingTimeout');
|
|
2850
|
+
this.__sendingTimeout = sendingTimeout;
|
|
2851
|
+
this.__renewStale = false;
|
|
2852
|
+
this.__liveAtAttempt = false;
|
|
2670
2853
|
this.__task = task;
|
|
2671
2854
|
this.__queue = queue;
|
|
2672
2855
|
this.__shouldAbort = shouldAbort;
|
|
@@ -2674,6 +2857,8 @@ class RecipientPolicyLease {
|
|
|
2674
2857
|
this.__closed = false;
|
|
2675
2858
|
this.__finishing = false;
|
|
2676
2859
|
this.__tail = Promise.resolve();
|
|
2860
|
+
this.__renewUncertain = false;
|
|
2861
|
+
this.__attemptedAt = 0;
|
|
2677
2862
|
this.__timer = null;
|
|
2678
2863
|
let pending = false;
|
|
2679
2864
|
let renewals = 0;
|
|
@@ -2688,6 +2873,8 @@ class RecipientPolicyLease {
|
|
|
2688
2873
|
renewals++;
|
|
2689
2874
|
this.__enqueue(async (guard) => {
|
|
2690
2875
|
const fields = { isSending: true, sendingAt: Math.max(Date.now(), this.__task.sendingAt + 1) };
|
|
2876
|
+
this.__attemptedAt = fields.sendingAt;
|
|
2877
|
+
this.__liveAtAttempt = this.__task.sendingAt > fields.sendingAt - this.__sendingTimeout;
|
|
2691
2878
|
return await this.__write(fields, guard);
|
|
2692
2879
|
}).finally(() => { pending = false; });
|
|
2693
2880
|
}, interval);
|
|
@@ -2707,17 +2894,41 @@ class RecipientPolicyLease {
|
|
|
2707
2894
|
this.__closed = true;
|
|
2708
2895
|
}
|
|
2709
2896
|
|
|
2710
|
-
__enqueue(operation) {
|
|
2897
|
+
__enqueue(operation, phase = 'renew') {
|
|
2711
2898
|
const pending = this.__tail.then(async () => {
|
|
2712
2899
|
if (!this.active) return false;
|
|
2713
2900
|
const guard = { leaseTries: this.__task.tries, leaseSendingAt: this.__task.sendingAt };
|
|
2714
2901
|
try {
|
|
2715
|
-
|
|
2716
|
-
if (!ok)
|
|
2902
|
+
let ok = await operation(guard);
|
|
2903
|
+
if (!ok) {
|
|
2904
|
+
// A renewal write that threw may have been applied by storage, which leaves the
|
|
2905
|
+
// guard stale. Retry once with the attempted stamp: a peer takeover bumps tries or
|
|
2906
|
+
// stamps a later sendingAt while the lease is live, so the retry cannot match a
|
|
2907
|
+
// taken-over row. Report if it still fails.
|
|
2908
|
+
if (this.__renewStale && phase !== 'renew') {
|
|
2909
|
+
// The claim was already stale when the renewal threw: no retry, report the loss.
|
|
2910
|
+
this.__renewStale = false;
|
|
2911
|
+
this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
|
|
2912
|
+
} else if (this.__renewUncertain && phase !== 'renew') {
|
|
2913
|
+
this.__renewUncertain = false;
|
|
2914
|
+
if (this.__shouldAbort()) {
|
|
2915
|
+
this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
|
|
2916
|
+
this.__halt();
|
|
2917
|
+
return false;
|
|
2918
|
+
}
|
|
2919
|
+
ok = await operation({ ...guard, leaseSendingAt: this.__attemptedAt });
|
|
2920
|
+
if (ok && phase === 'checkpoint') this.__task.sendingAt = this.__attemptedAt;
|
|
2921
|
+
if (!ok) this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
|
|
2922
|
+
}
|
|
2923
|
+
if (!ok) this.__halt();
|
|
2924
|
+
}
|
|
2717
2925
|
return ok;
|
|
2718
2926
|
} catch (error) {
|
|
2719
|
-
|
|
2720
|
-
|
|
2927
|
+
// A thrown renewal write only stops further renewals. The lease stays open so
|
|
2928
|
+
// finish() still records the outcome; storage-side CAS protects row ownership.
|
|
2929
|
+
if (phase === 'renew') { this.__clearTimer(); this.__renewUncertain = this.__liveAtAttempt; this.__renewStale = !this.__liveAtAttempt; }
|
|
2930
|
+
else this.__halt();
|
|
2931
|
+
this.__report(error, phase);
|
|
2721
2932
|
return false;
|
|
2722
2933
|
}
|
|
2723
2934
|
});
|
|
@@ -2733,7 +2944,7 @@ class RecipientPolicyLease {
|
|
|
2733
2944
|
|
|
2734
2945
|
update(fields) {
|
|
2735
2946
|
if (this.__finishing) return Promise.resolve(false);
|
|
2736
|
-
return this.__enqueue((guard) => this.__write(fields, guard));
|
|
2947
|
+
return this.__enqueue((guard) => this.__write(fields, guard), 'checkpoint');
|
|
2737
2948
|
}
|
|
2738
2949
|
|
|
2739
2950
|
finish(fields, remove) {
|
|
@@ -2745,7 +2956,7 @@ class RecipientPolicyLease {
|
|
|
2745
2956
|
if (ok) Object.assign(this.__task, fields);
|
|
2746
2957
|
this.__halt();
|
|
2747
2958
|
return ok;
|
|
2748
|
-
});
|
|
2959
|
+
}, 'complete');
|
|
2749
2960
|
}
|
|
2750
2961
|
|
|
2751
2962
|
async stop() {
|
|
@@ -3023,9 +3234,21 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
3023
3234
|
*/
|
|
3024
3235
|
|
|
3025
3236
|
/**
|
|
3026
|
-
* @typedef {{
|
|
3237
|
+
* @typedef {{ failedWrites: number }} MailTimeDrainResult
|
|
3238
|
+
*/
|
|
3239
|
+
|
|
3240
|
+
/**
|
|
3241
|
+
* `details` passed to `onError`. `phase` is `'verify'` (transport verification, `email === null`), `'complete'` (a storage write recording a send outcome), or `'checkpoint'` (recipient-policy results written after SMTP). Without `phase` it is the SMTP `info` of the failed attempt.
|
|
3242
|
+
* @typedef {{ phase?: 'verify' | 'complete' | 'checkpoint', transportIndex?: number, attempt?: number, [key: string]: unknown }} MailTimeErrorDetails
|
|
3027
3243
|
*/
|
|
3028
3244
|
|
|
3245
|
+
/**
|
|
3246
|
+
* @typedef {{ queue: RedisQueue | MongoQueue | PostgresQueue | CustomQueue, type?: 'server' | 'client', from?: string | ((transport: MailTimeTransport, details: MailTimeFromDetails) => string), transports?: MailTimeTransport[], strategy?: 'backup' | 'balancer', failsToNext?: number, shouldFailOver?: (error: unknown, info: object | undefined, email: MailTimeTask) => boolean, retries?: number, maxTries?: number, retryDelay?: number, interval?: number, keepHistory?: boolean, concatEmails?: boolean | MailTimeConcatEmailsOptions, concatSubject?: string, concatDelimiter?: string, concatDelay?: number, concatThrottling?: number, revolvingInterval?: number, mode?: 'one' | 'batch', concurrency?: number, sendingTimeout?: number, renewClaim?: boolean | number, maxRenewals?: number, strictPayload?: boolean, allowedMailFields?: string[], verifyTransports?: boolean, verifyTimeout?: number, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, recipientPolicies?: MailTimeRecipientPolicy[], onError?: (error: unknown, email: MailTimeTask | null, details?: MailTimeErrorDetails, recipients?: MailTimeRecipientResult[], summary?: MailTimeRecipientSummary) => void | Promise<void>, onSent?: (email: MailTimeTask, details?: object, recipients?: MailTimeRecipientResult[], summary?: MailTimeRecipientSummary) => void | Promise<void>, onSuppressed?: (email: MailTimeTask, recipients: MailTimeRecipientResult[], summary: MailTimeRecipientSummary) => void | Promise<void>, onRejected?: (email: MailTimeTask, recipients: MailTimeRecipientResult[], summary: MailTimeRecipientSummary) => void | Promise<void> }} MailTimeOptions
|
|
3247
|
+
*/
|
|
3248
|
+
|
|
3249
|
+
const REPROBE_BASE_MS = 60000;
|
|
3250
|
+
const REPROBE_MAX_MS = 900000;
|
|
3251
|
+
|
|
3029
3252
|
/**
|
|
3030
3253
|
* Class of MailTime.
|
|
3031
3254
|
* With recipientPolicies, terminal callbacks receive grouped recipients and a complete summary after persistence.
|
|
@@ -3128,6 +3351,7 @@ class MailTime {
|
|
|
3128
3351
|
this.__readyPromise = null;
|
|
3129
3352
|
this.__schedulerTimer = null;
|
|
3130
3353
|
this.__inFlight = new Set();
|
|
3354
|
+
this.__failedWrites = 0;
|
|
3131
3355
|
this.__pool = createPool(this.concurrency);
|
|
3132
3356
|
|
|
3133
3357
|
this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
|
|
@@ -3135,7 +3359,12 @@ class MailTime {
|
|
|
3135
3359
|
this.transports = Array.isArray(opts.transports) ? opts.transports : [];
|
|
3136
3360
|
this.transport = 0;
|
|
3137
3361
|
this.verifyTransports = opts.verifyTransports !== false;
|
|
3362
|
+
this.verifyTimeout = (typeof opts.verifyTimeout === 'number' && opts.verifyTimeout > 0) ? Math.min(opts.verifyTimeout, 2147483647) : 30000;
|
|
3138
3363
|
this.__unhealthyTransports = new Set();
|
|
3364
|
+
this.__verifyGen = [];
|
|
3365
|
+
this.__reprobe = new Map();
|
|
3366
|
+
this.__probeCancels = new Set();
|
|
3367
|
+
this.__uncertainRenewals = new WeakMap();
|
|
3139
3368
|
|
|
3140
3369
|
if (typeof opts.from === 'string') {
|
|
3141
3370
|
const fromStr = opts.from;
|
|
@@ -3146,6 +3375,21 @@ class MailTime {
|
|
|
3146
3375
|
this.from = false;
|
|
3147
3376
|
}
|
|
3148
3377
|
|
|
3378
|
+
if (this.__recipientPolicies && this.type === 'server') {
|
|
3379
|
+
// A transport `from` that the policy parser rejects would settle every rotated letter as
|
|
3380
|
+
// failed; surface it here instead. A `from` callback is a runtime value and is not checked.
|
|
3381
|
+
if (typeof opts.from === 'string') normalizePolicyAddress(opts.from, 'from');
|
|
3382
|
+
this.transports.forEach((transport, i) => {
|
|
3383
|
+
const from = MailTime.transportFrom(transport);
|
|
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
|
+
}
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3149
3393
|
this.queue.mailTimeInstance = this;
|
|
3150
3394
|
|
|
3151
3395
|
/** @type {string} */
|
|
@@ -3318,6 +3562,8 @@ class MailTime {
|
|
|
3318
3562
|
|
|
3319
3563
|
this.__isDestroyed = true;
|
|
3320
3564
|
this.__isPaused = false;
|
|
3565
|
+
for (const cancel of [...this.__probeCancels]) cancel();
|
|
3566
|
+
this.__reprobe.clear();
|
|
3321
3567
|
// Queued jobs never started SMTP; dropping them lets a scan blocked on a
|
|
3322
3568
|
// pool slot return, so JoSk shutdown does not wait for in-flight sends.
|
|
3323
3569
|
this.__pool.cancelQueued();
|
|
@@ -3353,14 +3599,34 @@ class MailTime {
|
|
|
3353
3599
|
* @async
|
|
3354
3600
|
* @memberOf MailTime
|
|
3355
3601
|
* @name drain
|
|
3356
|
-
* @description Wait for all in-flight email send attempts to settle
|
|
3357
|
-
* @returns {Promise<
|
|
3602
|
+
* @description Wait for all in-flight email send attempts to settle. Resolves with `{ failedWrites }`, the count since this instance was created of failures while recording a send outcome: a storage write that threw, an exception in the completion path, or an outcome write lost after a claim-renewal error (message `outcome write lost (renewal outcome uncertain or lease taken over)`, after one retry with the renewal's stamp (no retry when the claim was already stale at renewal time)). Each is also reported once through `onError` with `details.phase` of `'complete'` or `'checkpoint'`, except after a plain `destroy()`, which still counts but suppresses the hook and the log. A row whose write failed can stay `sending` and be re-sent after `sendingTimeout`. Compare `failedWrites` before and after to scope a shutdown.
|
|
3603
|
+
* @returns {Promise<MailTimeDrainResult>}
|
|
3358
3604
|
*/
|
|
3359
3605
|
async drain() {
|
|
3360
3606
|
this.__debug('[drain]');
|
|
3361
3607
|
if (this.__pool) {
|
|
3362
3608
|
await this.__pool.drain();
|
|
3363
3609
|
}
|
|
3610
|
+
return { failedWrites: this.__failedWrites };
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
/**
|
|
3614
|
+
* @internal
|
|
3615
|
+
* @memberOf MailTime
|
|
3616
|
+
* @name ___reportWriteFailure
|
|
3617
|
+
* @description Count a failed completion step (a storage write that threw, an exception raised inside the completion path, or an outcome write lost after a claim-renewal error) and surface it once through `onError(error, task, { phase })`. Never throws. After a non-draining `destroy()` it still counts but skips the log and the hook.
|
|
3618
|
+
* @param {MailTimeTask} task
|
|
3619
|
+
* @param {unknown} error
|
|
3620
|
+
* @param {'complete' | 'checkpoint'} [phase]
|
|
3621
|
+
* @returns {void}
|
|
3622
|
+
*/
|
|
3623
|
+
___reportWriteFailure(task, error, phase = 'complete') {
|
|
3624
|
+
this.__failedWrites++;
|
|
3625
|
+
if (this.__abortInFlight) {
|
|
3626
|
+
return;
|
|
3627
|
+
}
|
|
3628
|
+
logError(`[private send] completion error, ${phase} write failed; row ${task?.uuid} may stay claimed and be re-sent after sendingTimeout`, error);
|
|
3629
|
+
callHook('onError', this.onError, error, task || null, { phase, attempt: task?.tries, transportIndex: task?.transport });
|
|
3364
3630
|
}
|
|
3365
3631
|
|
|
3366
3632
|
/**
|
|
@@ -3431,7 +3697,7 @@ class MailTime {
|
|
|
3431
3697
|
* @description add email to the queue or append to existing letter if {concatEmails: true}
|
|
3432
3698
|
* @param {MailTimeMailOptions} opts - email options
|
|
3433
3699
|
* @returns {Promise<string>} uuid of the email
|
|
3434
|
-
* @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)
|
|
3435
3701
|
*/
|
|
3436
3702
|
async sendMail(opts) {
|
|
3437
3703
|
opts = (opts && typeof opts === 'object') ? opts : {};
|
|
@@ -3465,6 +3731,10 @@ class MailTime {
|
|
|
3465
3731
|
throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
|
|
3466
3732
|
}
|
|
3467
3733
|
|
|
3734
|
+
if (this.__recipientPolicies) {
|
|
3735
|
+
validatePolicyMailOptions(mailOptions);
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3468
3738
|
if (this.concatEmails) {
|
|
3469
3739
|
sendAt = sendAt + this.concatDelay;
|
|
3470
3740
|
const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
|
|
@@ -3546,21 +3816,15 @@ class MailTime {
|
|
|
3546
3816
|
if (task.tries >= this.maxTries) {
|
|
3547
3817
|
this.___finalizeRejected(task, info);
|
|
3548
3818
|
|
|
3549
|
-
const
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
let finalized = false;
|
|
3559
|
-
if (!this.keepHistory) {
|
|
3560
|
-
finalized = await this.queue.remove(task, leaseRemove);
|
|
3561
|
-
} else {
|
|
3562
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
3563
|
-
}
|
|
3819
|
+
const finalized = await this.___outcomeWrite(task, (t) => (!this.keepHistory
|
|
3820
|
+
? this.queue.remove(t, __leaseRemoveOpts(t))
|
|
3821
|
+
: this.queue.update(t, __withLease(t, {
|
|
3822
|
+
isSent: false,
|
|
3823
|
+
isFailed: true,
|
|
3824
|
+
isSending: false,
|
|
3825
|
+
sendingAt: 0,
|
|
3826
|
+
mailOptions: task.mailOptions,
|
|
3827
|
+
}))));
|
|
3564
3828
|
|
|
3565
3829
|
if (!finalized) {
|
|
3566
3830
|
this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
|
|
@@ -3586,12 +3850,12 @@ class MailTime {
|
|
|
3586
3850
|
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
3587
3851
|
}
|
|
3588
3852
|
|
|
3589
|
-
const released = await this.queue.update(
|
|
3853
|
+
const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
|
|
3590
3854
|
isSending: false,
|
|
3591
3855
|
sendingAt: 0,
|
|
3592
3856
|
sendAt: Date.now() + this.retryDelay,
|
|
3593
3857
|
transport: transportIndex,
|
|
3594
|
-
}));
|
|
3858
|
+
})));
|
|
3595
3859
|
|
|
3596
3860
|
if (!released) {
|
|
3597
3861
|
this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
|
|
@@ -3993,6 +4257,10 @@ class MailTime {
|
|
|
3993
4257
|
}));
|
|
3994
4258
|
} catch (renewError) {
|
|
3995
4259
|
logError('[private renewClaim] storage error during claim renewal', renewError);
|
|
4260
|
+
// Storage may have applied the write before the driver failed; remember the stamp
|
|
4261
|
+
// so the outcome write can retry with it (see ___outcomeWrite).
|
|
4262
|
+
// A stale claim is remembered as null: the lost outcome write is reported, never retried.
|
|
4263
|
+
this.__uncertainRenewals.set(task, task.sendingAt > renewedAt - this.sendingTimeout ? renewedAt : null);
|
|
3996
4264
|
halt();
|
|
3997
4265
|
return;
|
|
3998
4266
|
}
|
|
@@ -4023,6 +4291,38 @@ class MailTime {
|
|
|
4023
4291
|
return { stop };
|
|
4024
4292
|
}
|
|
4025
4293
|
|
|
4294
|
+
/**
|
|
4295
|
+
* @async
|
|
4296
|
+
* @internal
|
|
4297
|
+
* @memberOf MailTime
|
|
4298
|
+
* @name ___outcomeWrite
|
|
4299
|
+
* @description Run a lease-guarded outcome write. `write(t)` must derive its lease guard from `t`. When the write loses the lease and an earlier claim renewal threw (storage may have applied it), retry once with that renewal's stamp; a peer takeover bumps `tries` or stamps a later `sendingAt` while the lease is live, so the retry cannot match a taken-over row. The stamp is only remembered when the lease was still live at renewal time. If the retry also fails, count and report the lost write. A claim that was already stale when the renewal threw is reported without a retry. After `destroy()` the loss is counted without hook or log. Without an earlier renewal error a lost lease stays a debug-only skip.
|
|
4300
|
+
* @param {MailTimeTask} task
|
|
4301
|
+
* @param {(t: MailTimeTask) => Promise<boolean>} write
|
|
4302
|
+
* @returns {Promise<boolean>}
|
|
4303
|
+
*/
|
|
4304
|
+
async ___outcomeWrite(task, write) {
|
|
4305
|
+
if (await write(task)) {
|
|
4306
|
+
this.__uncertainRenewals.delete(task);
|
|
4307
|
+
return true;
|
|
4308
|
+
}
|
|
4309
|
+
const attempted = this.__uncertainRenewals.get(task);
|
|
4310
|
+
if (attempted === void 0) {
|
|
4311
|
+
return false;
|
|
4312
|
+
}
|
|
4313
|
+
this.__uncertainRenewals.delete(task);
|
|
4314
|
+
const lost = new Error('outcome write lost (renewal outcome uncertain or lease taken over)');
|
|
4315
|
+
if (attempted === null || this.__abortInFlight) {
|
|
4316
|
+
this.___reportWriteFailure(task, lost, 'complete');
|
|
4317
|
+
return false;
|
|
4318
|
+
}
|
|
4319
|
+
if (await write({ ...task, sendingAt: attempted })) {
|
|
4320
|
+
return true;
|
|
4321
|
+
}
|
|
4322
|
+
this.___reportWriteFailure(task, lost, 'complete');
|
|
4323
|
+
return false;
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4026
4326
|
/** @internal Serialize every policy checkpoint with this attempt's claim renewal. */
|
|
4027
4327
|
async ___sendWithRecipientPolicies(task) {
|
|
4028
4328
|
if (!task || task.isSent || task.isFailed || task.isCancelled || task.isSettled || this.__abortInFlight) return;
|
|
@@ -4037,9 +4337,12 @@ class MailTime {
|
|
|
4037
4337
|
}
|
|
4038
4338
|
Object.assign(task, fields);
|
|
4039
4339
|
const lease = new RecipientPolicyLease({
|
|
4040
|
-
task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals,
|
|
4340
|
+
task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals, sendingTimeout: this.sendingTimeout,
|
|
4041
4341
|
shouldAbort: () => this.__abortInFlight,
|
|
4042
|
-
report: (error) =>
|
|
4342
|
+
report: (error, phase) => {
|
|
4343
|
+
if (phase === 'complete' || phase === 'checkpoint') this.___reportWriteFailure(task, error, phase);
|
|
4344
|
+
else if (!this.__abortInFlight) logError('[recipientPolicies] claim renewal write failed; renewals stop, the outcome write still runs', error);
|
|
4345
|
+
},
|
|
4043
4346
|
});
|
|
4044
4347
|
try {
|
|
4045
4348
|
if (!lease.active) return;
|
|
@@ -4095,7 +4398,15 @@ class MailTime {
|
|
|
4095
4398
|
context.recipients = context.recipients.filter((r) => eligible.has(r.address));
|
|
4096
4399
|
await this.___attemptPolicyTransport(task, lease, compiled, context);
|
|
4097
4400
|
} catch (error) {
|
|
4098
|
-
if (lease.active)
|
|
4401
|
+
if (!lease.active) return;
|
|
4402
|
+
if (isAddressError(error)) {
|
|
4403
|
+
// Unparseable addresses fail identically on every attempt, so settle now instead of
|
|
4404
|
+
// spending the retry budget. The message names the field, never the address.
|
|
4405
|
+
if (!this.__abortInFlight) logError(`[recipientPolicies] task ${task.uuid} failed without retry:`, error.message);
|
|
4406
|
+
await this.___completePolicyTask(task, lease, error, void 0, true);
|
|
4407
|
+
} else {
|
|
4408
|
+
await this.___retryPolicyTask(task, lease, error, void 0, true, true);
|
|
4409
|
+
}
|
|
4099
4410
|
} finally {
|
|
4100
4411
|
await lease.stop();
|
|
4101
4412
|
}
|
|
@@ -4104,7 +4415,7 @@ class MailTime {
|
|
|
4104
4415
|
/** @internal Persist SMTP acceptance before awaiting rejection classification or observation. */
|
|
4105
4416
|
async ___attemptPolicyTransport(task, lease, compiled, context) {
|
|
4106
4417
|
const transport = this.transports[context.transport.index];
|
|
4107
|
-
const outgoing = { ...compiled, envelope: context.envelope };
|
|
4418
|
+
const outgoing = { ...rewritePolicyHeaders(compiled), envelope: context.envelope };
|
|
4108
4419
|
const { error, info } = await new Promise((resolve) => {
|
|
4109
4420
|
let called = false;
|
|
4110
4421
|
const done = (error, info) => {
|
|
@@ -4312,6 +4623,7 @@ class MailTime {
|
|
|
4312
4623
|
task.tries = tries;
|
|
4313
4624
|
task.isSending = true;
|
|
4314
4625
|
task.sendingAt = sendingAt;
|
|
4626
|
+
this.__uncertainRenewals.delete(task);
|
|
4315
4627
|
|
|
4316
4628
|
let transportIndex = task.transport;
|
|
4317
4629
|
if (!this.___isHealthyTransport(transportIndex)) {
|
|
@@ -4365,20 +4677,14 @@ class MailTime {
|
|
|
4365
4677
|
if (isFullyDelivered) {
|
|
4366
4678
|
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
4367
4679
|
|
|
4368
|
-
const
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
let completed = false;
|
|
4377
|
-
if (!this.keepHistory) {
|
|
4378
|
-
completed = await this.queue.remove(task, leaseRemove);
|
|
4379
|
-
} else {
|
|
4380
|
-
completed = await this.queue.update(task, leaseUpdate);
|
|
4381
|
-
}
|
|
4680
|
+
const completed = await this.___outcomeWrite(task, (t) => (!this.keepHistory
|
|
4681
|
+
? this.queue.remove(t, __leaseRemoveOpts(t))
|
|
4682
|
+
: this.queue.update(t, __withLease(t, {
|
|
4683
|
+
isSent: true,
|
|
4684
|
+
isSending: false,
|
|
4685
|
+
sendingAt: 0,
|
|
4686
|
+
mailOptions: task.mailOptions,
|
|
4687
|
+
}))));
|
|
4382
4688
|
|
|
4383
4689
|
if (!completed) {
|
|
4384
4690
|
this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
|
|
@@ -4396,21 +4702,15 @@ class MailTime {
|
|
|
4396
4702
|
if (task.tries >= this.maxTries) {
|
|
4397
4703
|
this.___finalizeRejected(task, info);
|
|
4398
4704
|
|
|
4399
|
-
const
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
let finalized = false;
|
|
4409
|
-
if (!this.keepHistory) {
|
|
4410
|
-
finalized = await this.queue.remove(task, leaseRemove);
|
|
4411
|
-
} else {
|
|
4412
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
4413
|
-
}
|
|
4705
|
+
const finalized = await this.___outcomeWrite(task, (t) => (!this.keepHistory
|
|
4706
|
+
? this.queue.remove(t, __leaseRemoveOpts(t))
|
|
4707
|
+
: this.queue.update(t, __withLease(t, {
|
|
4708
|
+
isSent: false,
|
|
4709
|
+
isFailed: true,
|
|
4710
|
+
isSending: false,
|
|
4711
|
+
sendingAt: 0,
|
|
4712
|
+
mailOptions: task.mailOptions,
|
|
4713
|
+
}))));
|
|
4414
4714
|
|
|
4415
4715
|
if (!finalized) {
|
|
4416
4716
|
this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
|
|
@@ -4435,19 +4735,19 @@ class MailTime {
|
|
|
4435
4735
|
}
|
|
4436
4736
|
|
|
4437
4737
|
const nextSendAt = Date.now() + this.retryDelay;
|
|
4438
|
-
const released = await this.queue.update(
|
|
4738
|
+
const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
|
|
4439
4739
|
isSending: false,
|
|
4440
4740
|
sendingAt: 0,
|
|
4441
4741
|
sendAt: nextSendAt,
|
|
4442
4742
|
mailOptions: task.mailOptions,
|
|
4443
|
-
}));
|
|
4743
|
+
})));
|
|
4444
4744
|
if (!released) {
|
|
4445
4745
|
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
4446
4746
|
return;
|
|
4447
4747
|
}
|
|
4448
4748
|
this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
|
|
4449
4749
|
} catch (completionError) {
|
|
4450
|
-
|
|
4750
|
+
this.___reportWriteFailure(task, completionError);
|
|
4451
4751
|
} finally {
|
|
4452
4752
|
resolve();
|
|
4453
4753
|
}
|
|
@@ -4463,7 +4763,11 @@ class MailTime {
|
|
|
4463
4763
|
return;
|
|
4464
4764
|
}
|
|
4465
4765
|
logError('Exception during runtime:', e);
|
|
4466
|
-
|
|
4766
|
+
try {
|
|
4767
|
+
await this.___handleError(task, e, {});
|
|
4768
|
+
} catch (completionError) {
|
|
4769
|
+
this.___reportWriteFailure(task, completionError);
|
|
4770
|
+
}
|
|
4467
4771
|
}
|
|
4468
4772
|
}
|
|
4469
4773
|
|
|
@@ -4479,6 +4783,9 @@ class MailTime {
|
|
|
4479
4783
|
if (this.__isDestroyed || this.__isPaused) {
|
|
4480
4784
|
return;
|
|
4481
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);
|
|
4482
4789
|
const limit = this.mode === 'one' ? 1 : Infinity;
|
|
4483
4790
|
this.__schedulerScans++;
|
|
4484
4791
|
try {
|
|
@@ -4525,51 +4832,183 @@ class MailTime {
|
|
|
4525
4832
|
* @internal
|
|
4526
4833
|
* @memberOf MailTime
|
|
4527
4834
|
* @name ___verifyTransports
|
|
4528
|
-
* @description Probe each transport's `verify()` once at startup.
|
|
4835
|
+
* @description Probe each transport's `verify()` once at startup, in parallel. A transport whose probe fails is quarantined (skipped during rotation) and reported through `onError(error, null, { transportIndex, phase: 'verify' })`. A probe that neither settles nor fails within `verifyTimeout` stops delaying `ready()` and leaves the transport usable; a verdict that arrives later still applies. Throws if every transport is quarantined.
|
|
4529
4836
|
* @returns {Promise<void>}
|
|
4530
4837
|
*/
|
|
4531
4838
|
async ___verifyTransports() {
|
|
4532
4839
|
this.__debug('[private verifyTransports]');
|
|
4533
|
-
|
|
4840
|
+
await Promise.all(this.transports.map(async (transport, index) => {
|
|
4534
4841
|
if (!transport || typeof transport.verify !== 'function') {
|
|
4535
|
-
return
|
|
4842
|
+
return;
|
|
4843
|
+
}
|
|
4844
|
+
const outcome = await this.___verifyOne(transport, index);
|
|
4845
|
+
if (outcome === 'timeout' && !this.__isDestroyed) {
|
|
4846
|
+
console.warn(`[WARN] [mail-time] [verifyTransports] transport #${index} verify() gave no verdict within ${this.verifyTimeout}ms; keeping it usable, a late verdict still applies`);
|
|
4536
4847
|
}
|
|
4848
|
+
}));
|
|
4849
|
+
|
|
4850
|
+
if (this.__unhealthyTransports.size === this.transports.length) {
|
|
4851
|
+
throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
|
|
4852
|
+
}
|
|
4853
|
+
}
|
|
4854
|
+
|
|
4855
|
+
/**
|
|
4856
|
+
* @internal
|
|
4857
|
+
* @memberOf MailTime
|
|
4858
|
+
* @name ___verifyOne
|
|
4859
|
+
* @description Start one `transport.verify(callback)` probe bounded by `verifyTimeout`. A callback is always passed. The first of the callback, a returned thenable, a synchronous throw, or a synchronous `true`/`false` decides the verdict. `false` is what Nodemailer's `Mailer#verify` returns, without calling back, when the underlying transport has no `verify` method. Any other synchronous non-thenable return value (for example the timer handle an expression-bodied arrow returns) is ignored, so a callback that reports an error later still counts, and a verify that never calls back waits for the timeout. The verdict is applied through `___applyVerdict` even when it arrives after the timeout, guarded by a per-transport generation. Resolves `'ok'`, `'fail'`, or `'timeout'` (the wait ended without a verdict or the instance was destroyed).
|
|
4860
|
+
* @param {MailTimeTransport} transport
|
|
4861
|
+
* @param {number} index
|
|
4862
|
+
* @returns {Promise<'ok' | 'fail' | 'timeout'>}
|
|
4863
|
+
*/
|
|
4864
|
+
___verifyOne(transport, index) {
|
|
4865
|
+
const gen = (this.__verifyGen[index] || 0) + 1;
|
|
4866
|
+
this.__verifyGen[index] = gen;
|
|
4867
|
+
return new Promise((resolve) => {
|
|
4868
|
+
let decided = false;
|
|
4869
|
+
let waiting = true;
|
|
4870
|
+
const stopWaiting = (outcome) => {
|
|
4871
|
+
if (!waiting) {
|
|
4872
|
+
return;
|
|
4873
|
+
}
|
|
4874
|
+
waiting = false;
|
|
4875
|
+
clearTimeout(timer);
|
|
4876
|
+
this.__probeCancels.delete(cancel);
|
|
4877
|
+
resolve(outcome);
|
|
4878
|
+
};
|
|
4879
|
+
const cancel = () => stopWaiting('timeout');
|
|
4880
|
+
const timer = setTimeout(cancel, this.verifyTimeout);
|
|
4881
|
+
if (typeof timer.unref === 'function') {
|
|
4882
|
+
timer.unref();
|
|
4883
|
+
}
|
|
4884
|
+
this.__probeCancels.add(cancel);
|
|
4885
|
+
|
|
4886
|
+
const decide = (error) => {
|
|
4887
|
+
if (decided) {
|
|
4888
|
+
return;
|
|
4889
|
+
}
|
|
4890
|
+
decided = true;
|
|
4891
|
+
this.___applyVerdict(index, gen, error);
|
|
4892
|
+
stopWaiting(error ? 'fail' : 'ok');
|
|
4893
|
+
};
|
|
4894
|
+
|
|
4537
4895
|
try {
|
|
4538
|
-
|
|
4539
|
-
|
|
4896
|
+
const returned = transport.verify((error) => decide(error || void 0));
|
|
4897
|
+
if (returned && typeof returned.then === 'function') {
|
|
4898
|
+
returned.then(() => decide(), (error) => decide(error || new Error('[mail-time] [verifyTransports] verify() rejected')));
|
|
4899
|
+
} else if (returned === true || returned === false) {
|
|
4900
|
+
decide();
|
|
4901
|
+
}
|
|
4540
4902
|
} catch (error) {
|
|
4541
|
-
|
|
4903
|
+
decide(error || new Error('[mail-time] [verifyTransports] verify() threw'));
|
|
4542
4904
|
}
|
|
4543
|
-
})
|
|
4905
|
+
});
|
|
4906
|
+
}
|
|
4544
4907
|
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4908
|
+
/**
|
|
4909
|
+
* @internal
|
|
4910
|
+
* @memberOf MailTime
|
|
4911
|
+
* @name ___applyVerdict
|
|
4912
|
+
* @description Apply a probe verdict to the health state. Ignored after `destroy()` and when a newer probe of the same transport has started (generation guard). Success removes the transport from quarantine. Failure quarantines it and fires `onError` once per quarantine episode; a failure for an already quarantined transport releases its re-probe slot and doubles the backoff.
|
|
4913
|
+
* @param {number} index
|
|
4914
|
+
* @param {number} gen
|
|
4915
|
+
* @param {unknown} [error]
|
|
4916
|
+
* @returns {void}
|
|
4917
|
+
*/
|
|
4918
|
+
___applyVerdict(index, gen, error) {
|
|
4919
|
+
if (this.__isDestroyed || this.__verifyGen[index] !== gen) {
|
|
4920
|
+
return;
|
|
4921
|
+
}
|
|
4922
|
+
const quarantined = this.__unhealthyTransports.has(index);
|
|
4923
|
+
if (!error) {
|
|
4924
|
+
if (!quarantined) {
|
|
4925
|
+
return;
|
|
4548
4926
|
}
|
|
4549
|
-
this.__unhealthyTransports.
|
|
4550
|
-
|
|
4551
|
-
|
|
4927
|
+
this.__unhealthyTransports.delete(index);
|
|
4928
|
+
this.__reprobe.delete(index);
|
|
4929
|
+
if (this.strategy === 'backup' && index < this.transport) {
|
|
4930
|
+
this.transport = index;
|
|
4931
|
+
}
|
|
4932
|
+
console.log(`[mail-time] [verifyTransports] transport #${index} recovered; back in rotation`);
|
|
4933
|
+
return;
|
|
4552
4934
|
}
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4935
|
+
if (quarantined) {
|
|
4936
|
+
const st = this.__reprobe.get(index);
|
|
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');
|
|
4939
|
+
return;
|
|
4556
4940
|
}
|
|
4941
|
+
this.__unhealthyTransports.add(index);
|
|
4942
|
+
this.__reprobe.set(index, { count: 0, nextProbeAt: Date.now() + REPROBE_BASE_MS, inFlight: false });
|
|
4943
|
+
if (this.transport === index && this.__unhealthyTransports.size < this.transports.length) {
|
|
4944
|
+
this.transport = this.___nextHealthyTransport(index);
|
|
4945
|
+
}
|
|
4946
|
+
logError(`[mail-time] [verifyTransports] transport #${index} failed verification`, error);
|
|
4947
|
+
callHook('onError', this.onError, error, null, { transportIndex: index, phase: 'verify' });
|
|
4948
|
+
}
|
|
4557
4949
|
|
|
4558
|
-
|
|
4559
|
-
|
|
4950
|
+
/**
|
|
4951
|
+
* @internal
|
|
4952
|
+
* @memberOf MailTime
|
|
4953
|
+
* @name ___maybeReprobe
|
|
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()`.
|
|
4955
|
+
* @param {number} index
|
|
4956
|
+
* @returns {void}
|
|
4957
|
+
*/
|
|
4958
|
+
___maybeReprobe(index) {
|
|
4959
|
+
if (this.__isDestroyed || this.type !== 'server' || !this.verifyTransports) {
|
|
4960
|
+
return;
|
|
4961
|
+
}
|
|
4962
|
+
const st = this.__reprobe.get(index);
|
|
4963
|
+
const transport = this.transports[index];
|
|
4964
|
+
if (!st || st.inFlight || st.nextProbeAt > Date.now() || !transport || typeof transport.verify !== 'function') {
|
|
4965
|
+
return;
|
|
4560
4966
|
}
|
|
4967
|
+
st.inFlight = true;
|
|
4968
|
+
this.__debug(`[private reprobe] transport #${index}, attempt after ${st.count} failure(s)`);
|
|
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)}`);
|
|
4561
4996
|
}
|
|
4562
4997
|
|
|
4563
4998
|
/**
|
|
4564
4999
|
* @internal
|
|
4565
5000
|
* @memberOf MailTime
|
|
4566
5001
|
* @name ___isHealthyTransport
|
|
4567
|
-
* @description Return true when the transport at `index`
|
|
5002
|
+
* @description Return true when the transport at `index` is not quarantined. A quarantined transport whose backoff has elapsed triggers a background re-probe; this call still returns false.
|
|
4568
5003
|
* @param {number} index
|
|
4569
5004
|
* @returns {boolean}
|
|
4570
5005
|
*/
|
|
4571
5006
|
___isHealthyTransport(index) {
|
|
4572
|
-
|
|
5007
|
+
if (!this.__unhealthyTransports.has(index)) {
|
|
5008
|
+
return true;
|
|
5009
|
+
}
|
|
5010
|
+
this.___maybeReprobe(index);
|
|
5011
|
+
return false;
|
|
4573
5012
|
}
|
|
4574
5013
|
|
|
4575
5014
|
/**
|