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/index.cjs CHANGED
@@ -304,20 +304,20 @@ const DEFAULT_PREFIX$2 = '';
304
304
  /**
305
305
  * @typedef {object} MongoCollection
306
306
  * @property {string} [collectionName]
307
- * @property {(keys: object, opts?: object) => Promise<unknown>} createIndex
308
- * @property {() => Promise<{ name: string, key: Record<string, unknown> }[]>} indexes
307
+ * @property {(keys: any, opts?: any) => Promise<unknown>} createIndex
308
+ * @property {() => Promise<{ name?: string, key: Record<string, unknown> }[]>} indexes
309
309
  * @property {(name: string) => Promise<unknown>} dropIndex
310
- * @property {(query: object, opts?: object) => unknown} find
311
- * @property {(query: object, opts?: object) => Promise<object|null>} findOne
312
- * @property {(doc: object) => Promise<unknown>} insertOne
313
- * @property {(query: object) => Promise<{ deletedCount?: number }>} deleteOne
314
- * @property {(query: object, update: object) => Promise<{ modifiedCount?: number }>} updateOne
310
+ * @property {(query: any, opts?: any) => unknown} find
311
+ * @property {(query: any, opts?: any) => Promise<object|null>} findOne
312
+ * @property {(doc: any) => Promise<unknown>} insertOne
313
+ * @property {(query: any) => Promise<{ deletedCount?: number }>} deleteOne
314
+ * @property {(query: any, update: any) => Promise<{ modifiedCount?: number, matchedCount?: number }>} updateOne
315
315
  */
316
316
 
317
317
  /**
318
318
  * @typedef {object} Db
319
319
  * @property {(name: string) => MongoCollection} collection
320
- * @property {(cmd: object) => Promise<{ ok?: number }>} command
320
+ * @property {(cmd: any) => Promise<{ ok?: number }>} command
321
321
  */
322
322
 
323
323
  /**
@@ -748,7 +748,7 @@ class MongoQueue {
748
748
  * @property {() => Promise<string>} [ping]
749
749
  * @property {() => unknown} [getRandomNode]
750
750
  * @property {(...args: any[]) => any} [nodeClient]
751
- * @property {(firstKey: string, isReadonly: boolean, args: string[]) => Promise<unknown>} [sendCommand]
751
+ * @property {(...args: any[]) => Promise<unknown>} [sendCommand]
752
752
  * @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
753
753
  * @property {(key: string, field: string) => Promise<string|null|undefined>} [hGet]
754
754
  * @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
@@ -947,6 +947,12 @@ const isNoScriptError = (error) => {
947
947
  return !!error && (error.code === 'NOSCRIPT' || (typeof error.message === 'string' && error.message.includes('NOSCRIPT')));
948
948
  };
949
949
 
950
+ // node-redis 4/5 throw `WatchError` from `exec()` when a watched key changed; that is the
951
+ // expected outcome of a lost CAS race, not a storage failure.
952
+ const isWatchError = (error) => {
953
+ return !!error && (error.name === 'WatchError' || error.constructor?.name === 'WatchError');
954
+ };
955
+
950
956
  const canReleaseLease = (currentTask, updateObj) => {
951
957
  return currentTask
952
958
  && currentTask.tries === updateObj.leaseTries
@@ -1340,15 +1346,19 @@ class RedisQueue {
1340
1346
  const taskJSON = JSON.stringify(task);
1341
1347
 
1342
1348
  if (typeof this.client.multi === 'function') {
1343
- const multi = this.client.multi();
1344
- multi.set(letterKey, taskJSON);
1345
- multi.set(sendatKey, `${task.sendAt}`);
1346
- if (task.to) {
1347
- multi.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
1348
- PXAT: task.sendAt - 128,
1349
- });
1350
- }
1351
- await multi.exec();
1349
+ // EXEC discards every WATCH on the connection, so an unserialized push would turn a
1350
+ // concurrent claim's guarded MULTI into an unconditional write (duplicate send).
1351
+ await this.__serialize(async () => {
1352
+ const multi = this.client.multi();
1353
+ multi.set(letterKey, taskJSON);
1354
+ multi.set(sendatKey, `${task.sendAt}`);
1355
+ if (task.to) {
1356
+ multi.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
1357
+ PXAT: task.sendAt - 128,
1358
+ });
1359
+ }
1360
+ await multi.exec();
1361
+ });
1352
1362
  return;
1353
1363
  }
1354
1364
 
@@ -1385,7 +1395,7 @@ class RedisQueue {
1385
1395
  }
1386
1396
 
1387
1397
  /** @internal */
1388
- async __cancel(uuid) {
1398
+ async __cancel(uuid, attempt = 0) {
1389
1399
  this.__debug('[cancel]', uuid);
1390
1400
  if (typeof uuid !== 'string') {
1391
1401
  return false;
@@ -1406,7 +1416,10 @@ class RedisQueue {
1406
1416
  const args = keep
1407
1417
  ? [uuid, JSON.stringify({ isCancelled: true }), 'cancel', `${Date.now()}`, `${this.mailTimeInstance.sendingTimeout || 300000}`, '0', '0', payload, JSON.stringify({ ...task, isCancelled: true })]
1408
1418
  : [uuid, 'cancel', '0', '0'];
1409
- return Number(await this.__runScript(keep ? 'update' : 'remove', { keys, arguments: args })) >= 1;
1419
+ const result = Number(await this.__runScript(keep ? 'update' : 'remove', { keys, arguments: args }));
1420
+ // -1: the payload changed between hGet and the script (a claim renewal, for example); retry on a fresh snapshot
1421
+ if (result === -1 && attempt < 2) return await this.__cancel(uuid, attempt + 1);
1422
+ return result >= 1;
1410
1423
  }
1411
1424
  if (!atomic) {
1412
1425
  if (task.recipientResults != null) return false;
@@ -1418,7 +1431,7 @@ class RedisQueue {
1418
1431
  multi.del(this.__getKey(uuid, 'sendat'));
1419
1432
  return (await multi.exec()) !== null;
1420
1433
  } catch (error) {
1421
- logError('[cancel] storage error', error);
1434
+ if (!isWatchError(error)) logError('[cancel] storage error', error);
1422
1435
  return false;
1423
1436
  } finally {
1424
1437
  if (!this.useHashTags && atomic) await this.client.unwatch?.();
@@ -1475,7 +1488,9 @@ class RedisQueue {
1475
1488
  return false;
1476
1489
  }
1477
1490
  try {
1491
+ const concatKey = task.to ? this.__getKey(task.to, 'concatletter') : null;
1478
1492
  await this.client.watch(letterKey);
1493
+ if (concatKey) await this.client.watch(concatKey);
1479
1494
  const taskJSON = await this.client.get(letterKey);
1480
1495
  if (!taskJSON) {
1481
1496
  await this.client.unwatch?.();
@@ -1493,8 +1508,9 @@ class RedisQueue {
1493
1508
  return false;
1494
1509
  }
1495
1510
  const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
1496
- if (task.to) {
1497
- keysToDelete.push(this.__getKey(task.to, 'concatletter'));
1511
+ // The pointer may already name a newer letter folded for the same recipient.
1512
+ if (concatKey && (await this.client.get(concatKey)) === task.uuid) {
1513
+ keysToDelete.push(concatKey);
1498
1514
  }
1499
1515
  const multi = this.client.multi();
1500
1516
  for (const key of keysToDelete) {
@@ -1503,7 +1519,7 @@ class RedisQueue {
1503
1519
  const result = await multi.exec();
1504
1520
  return result !== null;
1505
1521
  } catch (opError) {
1506
- logError('[remove] [lease] [opError]', opError);
1522
+ if (!isWatchError(opError)) logError('[remove] [lease] [opError]', opError);
1507
1523
  return false;
1508
1524
  }
1509
1525
  }
@@ -1515,7 +1531,10 @@ class RedisQueue {
1515
1531
 
1516
1532
  const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
1517
1533
  if (task.to) {
1518
- keysToDelete.push(this.__getKey(task.to, 'concatletter'));
1534
+ const concatKey = this.__getKey(task.to, 'concatletter');
1535
+ if ((await this.client.get(concatKey)) === task.uuid) {
1536
+ keysToDelete.push(concatKey);
1537
+ }
1519
1538
  }
1520
1539
  await this.client.del(keysToDelete);
1521
1540
  return true;
@@ -1675,7 +1694,7 @@ class RedisQueue {
1675
1694
  }
1676
1695
  return true;
1677
1696
  } catch (opError) {
1678
- logError('[update] [try/catch] [opError]', opError);
1697
+ if (!isWatchError(opError)) logError('[update] [try/catch] [opError]', opError);
1679
1698
  return false;
1680
1699
  }
1681
1700
  }
@@ -1716,6 +1735,8 @@ class RedisQueue {
1716
1735
  /**
1717
1736
  * @typedef {object} PostgresClient
1718
1737
  * @property {(queryText: string, values?: unknown[]) => Promise<PostgresQueryResult>} query
1738
+ * @property {(...args: any[]) => any} [connect] - a client whose constructor name ends in `Pool` or that has a numeric `totalCount` (`pg.Pool`) is treated as a pool; setup checks out one connection
1739
+ * @property {number} [totalCount]
1719
1740
  */
1720
1741
 
1721
1742
  /**
@@ -1816,8 +1837,18 @@ class PostgresQueue {
1816
1837
  /** @internal */
1817
1838
  __applyPrefix(prefix) {
1818
1839
  this.prefix = prefix;
1819
- this.__readyPromise = this.__setup();
1820
- this.__readyPromise.catch(() => void 0);
1840
+ this.__startSetup();
1841
+ }
1842
+
1843
+ /** @internal */
1844
+ __startSetup() {
1845
+ const setup = this.__setup();
1846
+ this.__readyPromise = setup;
1847
+ // A failed setup (database still booting) must not poison every later call; the next
1848
+ // ready() retries it. The awaiting caller still sees this attempt's rejection.
1849
+ setup.catch(() => {
1850
+ if (this.__readyPromise === setup) this.__readyPromise = null;
1851
+ });
1821
1852
  }
1822
1853
 
1823
1854
  /** @internal */
@@ -1842,16 +1873,25 @@ class PostgresQueue {
1842
1873
  async ready() {
1843
1874
  this.__ensurePrefix();
1844
1875
  this.__debug('[ready]');
1876
+ if (!this.__readyPromise) this.__startSetup();
1845
1877
  await this.__readyPromise;
1846
1878
  }
1847
1879
 
1848
1880
  /** @internal */
1849
1881
  async __setup() {
1850
1882
  const advisoryLockKey = advisoryLockKeyFor(this.prefix);
1851
- await this.client.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1883
+ // `pg_advisory_lock` is session-scoped. Through a `pg.Pool` each `query()` may use a
1884
+ // different connection, so lock, DDL and unlock must share one checked-out client;
1885
+ // otherwise the lock stays on an idle pooled connection and blocks peer startups.
1886
+ const pooled = typeof this.client.connect === 'function'
1887
+ && (typeof this.client.totalCount === 'number' || /Pool$/.test(this.client.constructor?.name || ''));
1888
+ const conn = pooled ? await this.client.connect() : this.client;
1889
+ let locked = false;
1852
1890
 
1853
1891
  try {
1854
- await this.client.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
1892
+ await conn.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1893
+ locked = true;
1894
+ await conn.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
1855
1895
  id BIGSERIAL PRIMARY KEY,
1856
1896
  prefix TEXT NOT NULL DEFAULT 'default',
1857
1897
  uuid TEXT NOT NULL,
@@ -1872,18 +1912,22 @@ class PostgresQueue {
1872
1912
  created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
1873
1913
  updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
1874
1914
  )`);
1875
- await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS is_settled BOOLEAN NOT NULL DEFAULT false');
1876
- await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS recipient_results JSONB');
1877
- await this.client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
1915
+ await conn.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS is_settled BOOLEAN NOT NULL DEFAULT false');
1916
+ await conn.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS recipient_results JSONB');
1917
+ await conn.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
1878
1918
  ON mail_time_queue (prefix, uuid)`);
1879
1919
 
1880
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_due_v1
1920
+ await conn.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_due_v1
1881
1921
  ON mail_time_queue (prefix, is_settled, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
1882
1922
 
1883
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_pending_to_v1
1923
+ await conn.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_pending_to_v1
1884
1924
  ON mail_time_queue (prefix, to_address, is_settled, is_sent, is_failed, is_cancelled, send_at)`);
1885
1925
  } finally {
1886
- await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1926
+ try {
1927
+ if (locked) await conn.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1928
+ } finally {
1929
+ if (pooled) conn.release();
1930
+ }
1887
1931
  }
1888
1932
  }
1889
1933
 
@@ -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 normalizePolicyAddress = (value) => {
2451
- const input = typeof value === 'string' ? value : (!Array.isArray(value) && value?.address);
2452
- if (typeof input !== 'string' || /[\r\n]/u.test(input)) {
2453
- throw policyError('recipient requires a simple address without line breaks');
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
- let address = input.trim();
2456
- if (address.includes('<') || address.includes('>')) {
2457
- const match = address.match(/^[^<>,;:"\r\n]*<([^<>]+)>$/u);
2458
- if (!match) throw policyError('use a simple explicit envelope.to');
2459
- address = match[1].trim();
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
- if (!SIMPLE_MAILBOX.test(address)) throw policyError('use a simple explicit envelope.to');
2462
- return address.toLowerCase();
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
- for (const entry of Array.isArray(value) ? value : [value]) {
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(entry); }
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
- const from = compiled.from || compiled.sender || compiled.replyTo;
2494
- if (from) envelope.from = normalizePolicyAddress(from);
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
- const ok = await operation(guard);
2716
- if (!ok) this.__halt();
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
- this.__halt();
2720
- if (!this.__shouldAbort()) this.__report(error);
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 {{ 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, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, recipientPolicies?: MailTimeRecipientPolicy[], onError?: (error: unknown, email: MailTimeTask | null, details?: object, 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
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<void>}
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 leaseRemove = __leaseRemoveOpts(task);
3550
- const leaseUpdate = __withLease(task, {
3551
- isSent: false,
3552
- isFailed: true,
3553
- isSending: false,
3554
- sendingAt: 0,
3555
- mailOptions: task.mailOptions,
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(task, __withLease(task, {
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) => logError('[recipientPolicies] lease persistence failed', 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) await this.___retryPolicyTask(task, lease, error, void 0, true, true);
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 leaseRemove = __leaseRemoveOpts(task);
4369
- const leaseUpdate = __withLease(task, {
4370
- isSent: true,
4371
- isSending: false,
4372
- sendingAt: 0,
4373
- mailOptions: task.mailOptions,
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 leaseRemove = __leaseRemoveOpts(task);
4400
- const leaseUpdate = __withLease(task, {
4401
- isSent: false,
4402
- isFailed: true,
4403
- isSending: false,
4404
- sendingAt: 0,
4405
- mailOptions: task.mailOptions,
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(task, __withLease(task, {
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
- logError('[private send] completion error after transport callback', completionError);
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
- await this.___handleError(task, e, {});
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. Failing transports are marked unusable and skipped during rotation; the failure is surfaced through `onError(error, null, { transportIndex, phase: 'verify' })`. Throws if every transport fails — there is nothing left that could deliver.
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
- const results = await Promise.all(this.transports.map(async (transport, index) => {
4840
+ await Promise.all(this.transports.map(async (transport, index) => {
4534
4841
  if (!transport || typeof transport.verify !== 'function') {
4535
- return { index, ok: true };
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
- await Promise.resolve(transport.verify());
4539
- return { index, ok: true };
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
- return { index, ok: false, error };
4903
+ decide(error || new Error('[mail-time] [verifyTransports] verify() threw'));
4542
4904
  }
4543
- }));
4905
+ });
4906
+ }
4544
4907
 
4545
- for (const r of results) {
4546
- if (r.ok) {
4547
- continue;
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.add(r.index);
4550
- logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
4551
- callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
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
- if (this.__unhealthyTransports.size === this.transports.length) {
4555
- throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
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
- if (this.__unhealthyTransports.has(this.transport)) {
4559
- this.transport = this.___nextHealthyTransport(this.transport);
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` has not been marked unusable by verification.
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
- return !this.__unhealthyTransports.has(index);
5007
+ if (!this.__unhealthyTransports.has(index)) {
5008
+ return true;
5009
+ }
5010
+ this.___maybeReprobe(index);
5011
+ return false;
4573
5012
  }
4574
5013
 
4575
5014
  /**