mail-time 5.2.1 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs CHANGED
@@ -2446,29 +2446,113 @@ const mailTimePreset = (name, overrides) => {
2446
2446
 
2447
2447
  const policyError = (message) => new Error(`[mail-time] [recipientPolicies] ${message}`);
2448
2448
  const SIMPLE_MAILBOX = /^[^\s<>,;:"()\\\[\]@]+@[^\s<>,;:"()\\\[\]@]+$/u;
2449
+ // Unquoted display-name characters that are auto-quoted in the sent header; `,` `;` `:` `<` `>` stay rejected.
2450
+ const QUOTABLE_SPECIALS = /[()[\]@\\]/u;
2451
+ const UNSAFE_CHARS = /[\r\n\u0000]/u;
2449
2452
 
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');
2453
+ /**
2454
+ * Error for an address that cannot be parsed into exactly one mailbox. The message names
2455
+ * the field and the rule that failed, never the address or display name itself.
2456
+ */
2457
+ const addressError = (field, reason) => {
2458
+ const error = policyError(`\`${field}\` ${reason}`);
2459
+ error.code = 'MAIL_TIME_INVALID_ADDRESS';
2460
+ error.field = field;
2461
+ return error;
2462
+ };
2463
+
2464
+ const isAddressError = (error) => error?.code === 'MAIL_TIME_INVALID_ADDRESS';
2465
+
2466
+ const toMailbox = (address, field) => {
2467
+ const trimmed = address.trim();
2468
+ if (!SIMPLE_MAILBOX.test(trimmed)) throw addressError(field, 'must contain one address such as user@example.com; quoted local parts are not supported');
2469
+ return trimmed.toLowerCase();
2470
+ };
2471
+
2472
+ /**
2473
+ * Parse one Nodemailer mailbox string: `addr`, `<addr>`, or `display name <addr>`, where the
2474
+ * display name is any mix of atoms and quoted strings with backslash escapes. Unquoted
2475
+ * `( ) [ ] @ \\` in a display name set `quote`, so the sent header carries the name quoted.
2476
+ * Groups, comma-separated lists, and text after `>` are rejected rather than guessed at.
2477
+ * @returns {{ name: string, address: string, quote: boolean }} `address` keeps its case.
2478
+ */
2479
+ const parseMailboxString = (input, field) => {
2480
+ let display = '';
2481
+ let name = '';
2482
+ let angle = null;
2483
+ let quoted = false;
2484
+ for (let i = 0; i < input.length; i++) {
2485
+ const char = input[i];
2486
+ if (quoted) {
2487
+ if (char === '\\') {
2488
+ if (++i >= input.length) break;
2489
+ name += input[i];
2490
+ } else if (char === '"') {
2491
+ quoted = false;
2492
+ } else {
2493
+ name += char;
2494
+ }
2495
+ continue;
2496
+ }
2497
+ if (char === '"') {
2498
+ quoted = true;
2499
+ } else if (char === '<') {
2500
+ const close = input.indexOf('>', i + 1);
2501
+ if (close === -1) throw addressError(field, 'has an unclosed angle bracket');
2502
+ angle = input.slice(i + 1, close);
2503
+ if (input.slice(close + 1).trim()) throw addressError(field, 'must end after the angle-bracket address; use an array for multiple recipients');
2504
+ break;
2505
+ } else if (char === ',') {
2506
+ throw addressError(field, 'must contain one mailbox; use an array for multiple recipients or quote a display name that contains a comma');
2507
+ } else if (char === ';' || char === ':') {
2508
+ throw addressError(field, 'must not use group syntax');
2509
+ } else if (char === '>') {
2510
+ throw addressError(field, 'has an unquoted `>` in its display name; wrap the name in double quotes');
2511
+ } else {
2512
+ display += char;
2513
+ name += char;
2514
+ }
2454
2515
  }
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();
2516
+ if (quoted) throw addressError(field, 'has an unterminated quoted display name');
2517
+ if (angle === null) {
2518
+ if (display !== input) throw addressError(field, 'must contain one address such as user@example.com; quoted local parts are not supported');
2519
+ toMailbox(input, field);
2520
+ return { name: '', address: input, quote: false };
2460
2521
  }
2461
- if (!SIMPLE_MAILBOX.test(address)) throw policyError('use a simple explicit envelope.to');
2462
- return address.toLowerCase();
2522
+ toMailbox(angle, field);
2523
+ return { name: name.replace(/\s+/gu, ' ').trim(), address: angle.trim(), quote: QUOTABLE_SPECIALS.test(display) };
2524
+ };
2525
+
2526
+ /**
2527
+ * Normalize exactly one mailbox (string or `{ name?, address }`) to its lowercase address.
2528
+ * @param {unknown} value
2529
+ * @param {string} [field] - Field label used in errors, e.g. `to[1]` or `envelope.from`.
2530
+ * @returns {string}
2531
+ */
2532
+ const normalizePolicyAddress = (value, field = 'address') => {
2533
+ if (typeof value === 'string') {
2534
+ if (UNSAFE_CHARS.test(value)) throw addressError(field, 'must not contain line breaks or NUL characters');
2535
+ if (!value.trim()) throw addressError(field, 'must not be empty');
2536
+ return parseMailboxString(value.trim(), field).address.toLowerCase();
2537
+ }
2538
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw addressError(field, 'must be an address string or a { name, address } object');
2539
+ if (typeof value.address !== 'string') throw addressError(field, 'object requires a string `address`');
2540
+ if (value.name !== void 0 && typeof value.name !== 'string') throw addressError(field, 'object `name` must be a string');
2541
+ if (UNSAFE_CHARS.test(value.address) || (value.name && UNSAFE_CHARS.test(value.name))) {
2542
+ throw addressError(field, 'must not contain line breaks or NUL characters');
2543
+ }
2544
+ return toMailbox(value.address, field);
2463
2545
  };
2464
2546
 
2465
2547
  const preparePolicyEnvelope = (compiled, previousResults = []) => {
2466
2548
  const recipients = new Map();
2467
2549
  const explicit = compiled.envelope && hasOwnProp(compiled.envelope, 'to');
2468
- const add = (value, source, authoritative) => {
2469
- for (const entry of Array.isArray(value) ? value : [value]) {
2550
+ const add = (value, source, authoritative, label = source) => {
2551
+ const list = Array.isArray(value);
2552
+ const entries = list ? value : [value];
2553
+ for (let i = 0; i < entries.length; i++) {
2470
2554
  let address;
2471
- try { address = normalizePolicyAddress(entry); }
2555
+ try { address = normalizePolicyAddress(entries[i], list ? `${label}[${i}]` : label); }
2472
2556
  catch (error) { if (authoritative) throw error; else continue; }
2473
2557
  if (!recipients.has(address)) {
2474
2558
  if (!authoritative) continue;
@@ -2478,7 +2562,7 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
2478
2562
  if (!sources.includes(source)) sources.push(source);
2479
2563
  }
2480
2564
  };
2481
- if (explicit) add(compiled.envelope.to, 'envelope', true);
2565
+ if (explicit) add(compiled.envelope.to, 'envelope', true, 'envelope.to');
2482
2566
  for (const source of ['to', 'cc', 'bcc']) {
2483
2567
  if (hasOwnProp(compiled, source) && compiled[source] !== void 0) add(compiled[source], source, !explicit);
2484
2568
  }
@@ -2488,14 +2572,61 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
2488
2572
  }
2489
2573
  const envelope = { to: [...recipients.keys()] };
2490
2574
  if (compiled.envelope && hasOwnProp(compiled.envelope, 'from')) {
2491
- envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from);
2575
+ envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from, 'envelope.from');
2492
2576
  } else {
2493
- const from = compiled.from || compiled.sender || compiled.replyTo;
2494
- if (from) envelope.from = normalizePolicyAddress(from);
2577
+ // Same precedence as Nodemailer's getEnvelope(): From, then Sender, then Reply-To.
2578
+ const field = ['from', 'sender', 'replyTo'].find((key) => compiled[key]);
2579
+ if (field) envelope.from = normalizePolicyAddress(compiled[field], field);
2495
2580
  }
2496
2581
  return { envelope, recipients: [...recipients.values()] };
2497
2582
  };
2498
2583
 
2584
+ /**
2585
+ * Return `compiled` with every header mailbox string whose display name needs quoting replaced by
2586
+ * `{ name, address }`, so Nodemailer renders the same mailbox the policy checked. Other entries,
2587
+ * including ones that do not parse, are left as they are. The input is never mutated.
2588
+ */
2589
+ const rewritePolicyHeaders = (compiled) => {
2590
+ let result = compiled;
2591
+ const rewrite = (value) => {
2592
+ if (typeof value !== 'string' || UNSAFE_CHARS.test(value)) return value;
2593
+ try {
2594
+ const { name, address, quote } = parseMailboxString(value.trim(), 'header');
2595
+ return quote ? { name, address } : value;
2596
+ } catch { return value; }
2597
+ };
2598
+ for (const key of ['from', 'sender', 'replyTo', 'to', 'cc', 'bcc']) {
2599
+ if (!hasOwnProp(compiled, key)) continue;
2600
+ const value = compiled[key];
2601
+ const next = Array.isArray(value) ? value.map(rewrite) : rewrite(value);
2602
+ if (Array.isArray(value) ? next.some((entry, i) => entry !== value[i]) : next !== value) {
2603
+ if (result === compiled) result = { ...compiled };
2604
+ result[key] = next;
2605
+ }
2606
+ }
2607
+ return result;
2608
+ };
2609
+
2610
+ /**
2611
+ * Validate every address field present in a letter with the policy parser, so an unparseable
2612
+ * address is rejected at `sendMail()` instead of after a queue round trip. Absent fields are
2613
+ * skipped. Throws the same `MAIL_TIME_INVALID_ADDRESS` error the send path would.
2614
+ * @param {object} mailOptions
2615
+ * @returns {void}
2616
+ */
2617
+ const validatePolicyMailOptions = (mailOptions) => {
2618
+ const check = (value, label) => {
2619
+ if (value === void 0) return;
2620
+ if (Array.isArray(value)) value.forEach((entry, i) => normalizePolicyAddress(entry, `${label}[${i}]`));
2621
+ else normalizePolicyAddress(value, label);
2622
+ };
2623
+ for (const key of ['from', 'sender', 'replyTo', 'to', 'cc', 'bcc']) check(mailOptions[key], key);
2624
+ if (isPlainObject(mailOptions.envelope)) {
2625
+ check(mailOptions.envelope.to, 'envelope.to');
2626
+ if (mailOptions.envelope.from !== '') check(mailOptions.envelope.from, 'envelope.from');
2627
+ }
2628
+ };
2629
+
2499
2630
  const validateRecipientPolicies = (value, queue) => {
2500
2631
  if (value === void 0) return null;
2501
2632
  if (!Array.isArray(value) || !value.length) throw policyError('recipientPolicies must be a nonempty array');
@@ -2532,7 +2663,7 @@ const validatePolicyResult = (raw, hook, context) => {
2532
2663
  const decisions = new Map();
2533
2664
  for (const item of raw.decisions) {
2534
2665
  if (!isPlainObject(item)) throw policyError('decision must be an object');
2535
- const address = normalizePolicyAddress(item.address);
2666
+ const address = normalizePolicyAddress(item.address, 'decision.address');
2536
2667
  if (!batch.has(address)) throw policyError('decision address is outside the input batch');
2537
2668
  const allowed = hook === 'beforeSend' ? item.status === 'suppressed' : (item.status === 'retry' || item.status === 'rejected');
2538
2669
  if (!allowed) throw policyError('invalid decision status for this phase');
@@ -2666,7 +2797,11 @@ const normalizeRejections = (error, info, transport) => {
2666
2797
  };
2667
2798
 
2668
2799
  class RecipientPolicyLease {
2669
- constructor({ task, queue, interval, maxRenewals, shouldAbort, report }) {
2800
+ constructor({ task, queue, interval, maxRenewals, sendingTimeout, shouldAbort, report }) {
2801
+ if (typeof sendingTimeout !== 'number' || !(sendingTimeout > 0)) throw new TypeError('[mail-time] [recipientPolicies] RecipientPolicyLease requires a positive sendingTimeout');
2802
+ this.__sendingTimeout = sendingTimeout;
2803
+ this.__renewStale = false;
2804
+ this.__liveAtAttempt = false;
2670
2805
  this.__task = task;
2671
2806
  this.__queue = queue;
2672
2807
  this.__shouldAbort = shouldAbort;
@@ -2674,6 +2809,8 @@ class RecipientPolicyLease {
2674
2809
  this.__closed = false;
2675
2810
  this.__finishing = false;
2676
2811
  this.__tail = Promise.resolve();
2812
+ this.__renewUncertain = false;
2813
+ this.__attemptedAt = 0;
2677
2814
  this.__timer = null;
2678
2815
  let pending = false;
2679
2816
  let renewals = 0;
@@ -2688,6 +2825,8 @@ class RecipientPolicyLease {
2688
2825
  renewals++;
2689
2826
  this.__enqueue(async (guard) => {
2690
2827
  const fields = { isSending: true, sendingAt: Math.max(Date.now(), this.__task.sendingAt + 1) };
2828
+ this.__attemptedAt = fields.sendingAt;
2829
+ this.__liveAtAttempt = this.__task.sendingAt > fields.sendingAt - this.__sendingTimeout;
2691
2830
  return await this.__write(fields, guard);
2692
2831
  }).finally(() => { pending = false; });
2693
2832
  }, interval);
@@ -2707,17 +2846,41 @@ class RecipientPolicyLease {
2707
2846
  this.__closed = true;
2708
2847
  }
2709
2848
 
2710
- __enqueue(operation) {
2849
+ __enqueue(operation, phase = 'renew') {
2711
2850
  const pending = this.__tail.then(async () => {
2712
2851
  if (!this.active) return false;
2713
2852
  const guard = { leaseTries: this.__task.tries, leaseSendingAt: this.__task.sendingAt };
2714
2853
  try {
2715
- const ok = await operation(guard);
2716
- if (!ok) this.__halt();
2854
+ let ok = await operation(guard);
2855
+ if (!ok) {
2856
+ // A renewal write that threw may have been applied by storage, which leaves the
2857
+ // guard stale. Retry once with the attempted stamp: a peer takeover bumps tries or
2858
+ // stamps a later sendingAt while the lease is live, so the retry cannot match a
2859
+ // taken-over row. Report if it still fails.
2860
+ if (this.__renewStale && phase !== 'renew') {
2861
+ // The claim was already stale when the renewal threw: no retry, report the loss.
2862
+ this.__renewStale = false;
2863
+ this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
2864
+ } else if (this.__renewUncertain && phase !== 'renew') {
2865
+ this.__renewUncertain = false;
2866
+ if (this.__shouldAbort()) {
2867
+ this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
2868
+ this.__halt();
2869
+ return false;
2870
+ }
2871
+ ok = await operation({ ...guard, leaseSendingAt: this.__attemptedAt });
2872
+ if (ok && phase === 'checkpoint') this.__task.sendingAt = this.__attemptedAt;
2873
+ if (!ok) this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
2874
+ }
2875
+ if (!ok) this.__halt();
2876
+ }
2717
2877
  return ok;
2718
2878
  } catch (error) {
2719
- this.__halt();
2720
- if (!this.__shouldAbort()) this.__report(error);
2879
+ // A thrown renewal write only stops further renewals. The lease stays open so
2880
+ // finish() still records the outcome; storage-side CAS protects row ownership.
2881
+ if (phase === 'renew') { this.__clearTimer(); this.__renewUncertain = this.__liveAtAttempt; this.__renewStale = !this.__liveAtAttempt; }
2882
+ else this.__halt();
2883
+ this.__report(error, phase);
2721
2884
  return false;
2722
2885
  }
2723
2886
  });
@@ -2733,7 +2896,7 @@ class RecipientPolicyLease {
2733
2896
 
2734
2897
  update(fields) {
2735
2898
  if (this.__finishing) return Promise.resolve(false);
2736
- return this.__enqueue((guard) => this.__write(fields, guard));
2899
+ return this.__enqueue((guard) => this.__write(fields, guard), 'checkpoint');
2737
2900
  }
2738
2901
 
2739
2902
  finish(fields, remove) {
@@ -2745,7 +2908,7 @@ class RecipientPolicyLease {
2745
2908
  if (ok) Object.assign(this.__task, fields);
2746
2909
  this.__halt();
2747
2910
  return ok;
2748
- });
2911
+ }, 'complete');
2749
2912
  }
2750
2913
 
2751
2914
  async stop() {
@@ -3023,9 +3186,21 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
3023
3186
  */
3024
3187
 
3025
3188
  /**
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
3189
+ * @typedef {{ failedWrites: number }} MailTimeDrainResult
3190
+ */
3191
+
3192
+ /**
3193
+ * `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.
3194
+ * @typedef {{ phase?: 'verify' | 'complete' | 'checkpoint', transportIndex?: number, attempt?: number, [key: string]: unknown }} MailTimeErrorDetails
3195
+ */
3196
+
3197
+ /**
3198
+ * @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
3027
3199
  */
3028
3200
 
3201
+ const REPROBE_BASE_MS = 60000;
3202
+ const REPROBE_MAX_MS = 900000;
3203
+
3029
3204
  /**
3030
3205
  * Class of MailTime.
3031
3206
  * With recipientPolicies, terminal callbacks receive grouped recipients and a complete summary after persistence.
@@ -3128,6 +3303,7 @@ class MailTime {
3128
3303
  this.__readyPromise = null;
3129
3304
  this.__schedulerTimer = null;
3130
3305
  this.__inFlight = new Set();
3306
+ this.__failedWrites = 0;
3131
3307
  this.__pool = createPool(this.concurrency);
3132
3308
 
3133
3309
  this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
@@ -3135,7 +3311,12 @@ class MailTime {
3135
3311
  this.transports = Array.isArray(opts.transports) ? opts.transports : [];
3136
3312
  this.transport = 0;
3137
3313
  this.verifyTransports = opts.verifyTransports !== false;
3314
+ this.verifyTimeout = (typeof opts.verifyTimeout === 'number' && opts.verifyTimeout > 0) ? Math.min(opts.verifyTimeout, 2147483647) : 30000;
3138
3315
  this.__unhealthyTransports = new Set();
3316
+ this.__verifyGen = [];
3317
+ this.__reprobe = new Map();
3318
+ this.__probeCancels = new Set();
3319
+ this.__uncertainRenewals = new WeakMap();
3139
3320
 
3140
3321
  if (typeof opts.from === 'string') {
3141
3322
  const fromStr = opts.from;
@@ -3146,6 +3327,16 @@ class MailTime {
3146
3327
  this.from = false;
3147
3328
  }
3148
3329
 
3330
+ if (this.__recipientPolicies && this.type === 'server') {
3331
+ // A transport `from` that the policy parser rejects would settle every rotated letter as
3332
+ // failed; surface it here instead. A `from` callback is a runtime value and is not checked.
3333
+ if (typeof opts.from === 'string') normalizePolicyAddress(opts.from, 'from');
3334
+ this.transports.forEach((transport, i) => {
3335
+ const from = MailTime.transportFrom(transport);
3336
+ if (from !== void 0) normalizePolicyAddress(from, `transports[${i}].from`);
3337
+ });
3338
+ }
3339
+
3149
3340
  this.queue.mailTimeInstance = this;
3150
3341
 
3151
3342
  /** @type {string} */
@@ -3318,6 +3509,8 @@ class MailTime {
3318
3509
 
3319
3510
  this.__isDestroyed = true;
3320
3511
  this.__isPaused = false;
3512
+ for (const cancel of [...this.__probeCancels]) cancel();
3513
+ this.__reprobe.clear();
3321
3514
  // Queued jobs never started SMTP; dropping them lets a scan blocked on a
3322
3515
  // pool slot return, so JoSk shutdown does not wait for in-flight sends.
3323
3516
  this.__pool.cancelQueued();
@@ -3353,14 +3546,34 @@ class MailTime {
3353
3546
  * @async
3354
3547
  * @memberOf MailTime
3355
3548
  * @name drain
3356
- * @description Wait for all in-flight email send attempts to settle
3357
- * @returns {Promise<void>}
3549
+ * @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.
3550
+ * @returns {Promise<MailTimeDrainResult>}
3358
3551
  */
3359
3552
  async drain() {
3360
3553
  this.__debug('[drain]');
3361
3554
  if (this.__pool) {
3362
3555
  await this.__pool.drain();
3363
3556
  }
3557
+ return { failedWrites: this.__failedWrites };
3558
+ }
3559
+
3560
+ /**
3561
+ * @internal
3562
+ * @memberOf MailTime
3563
+ * @name ___reportWriteFailure
3564
+ * @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.
3565
+ * @param {MailTimeTask} task
3566
+ * @param {unknown} error
3567
+ * @param {'complete' | 'checkpoint'} [phase]
3568
+ * @returns {void}
3569
+ */
3570
+ ___reportWriteFailure(task, error, phase = 'complete') {
3571
+ this.__failedWrites++;
3572
+ if (this.__abortInFlight) {
3573
+ return;
3574
+ }
3575
+ logError(`[private send] completion error, ${phase} write failed; row ${task?.uuid} may stay claimed and be re-sent after sendingTimeout`, error);
3576
+ callHook('onError', this.onError, error, task || null, { phase, attempt: task?.tries, transportIndex: task?.transport });
3364
3577
  }
3365
3578
 
3366
3579
  /**
@@ -3465,6 +3678,10 @@ class MailTime {
3465
3678
  throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
3466
3679
  }
3467
3680
 
3681
+ if (this.__recipientPolicies) {
3682
+ validatePolicyMailOptions(mailOptions);
3683
+ }
3684
+
3468
3685
  if (this.concatEmails) {
3469
3686
  sendAt = sendAt + this.concatDelay;
3470
3687
  const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
@@ -3546,21 +3763,15 @@ class MailTime {
3546
3763
  if (task.tries >= this.maxTries) {
3547
3764
  this.___finalizeRejected(task, info);
3548
3765
 
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
- }
3766
+ const finalized = await this.___outcomeWrite(task, (t) => (!this.keepHistory
3767
+ ? this.queue.remove(t, __leaseRemoveOpts(t))
3768
+ : this.queue.update(t, __withLease(t, {
3769
+ isSent: false,
3770
+ isFailed: true,
3771
+ isSending: false,
3772
+ sendingAt: 0,
3773
+ mailOptions: task.mailOptions,
3774
+ }))));
3564
3775
 
3565
3776
  if (!finalized) {
3566
3777
  this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
@@ -3586,12 +3797,12 @@ class MailTime {
3586
3797
  transportIndex = this.___nextHealthyTransport(transportIndex);
3587
3798
  }
3588
3799
 
3589
- const released = await this.queue.update(task, __withLease(task, {
3800
+ const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
3590
3801
  isSending: false,
3591
3802
  sendingAt: 0,
3592
3803
  sendAt: Date.now() + this.retryDelay,
3593
3804
  transport: transportIndex,
3594
- }));
3805
+ })));
3595
3806
 
3596
3807
  if (!released) {
3597
3808
  this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
@@ -3993,6 +4204,10 @@ class MailTime {
3993
4204
  }));
3994
4205
  } catch (renewError) {
3995
4206
  logError('[private renewClaim] storage error during claim renewal', renewError);
4207
+ // Storage may have applied the write before the driver failed; remember the stamp
4208
+ // so the outcome write can retry with it (see ___outcomeWrite).
4209
+ // A stale claim is remembered as null: the lost outcome write is reported, never retried.
4210
+ this.__uncertainRenewals.set(task, task.sendingAt > renewedAt - this.sendingTimeout ? renewedAt : null);
3996
4211
  halt();
3997
4212
  return;
3998
4213
  }
@@ -4023,6 +4238,38 @@ class MailTime {
4023
4238
  return { stop };
4024
4239
  }
4025
4240
 
4241
+ /**
4242
+ * @async
4243
+ * @internal
4244
+ * @memberOf MailTime
4245
+ * @name ___outcomeWrite
4246
+ * @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.
4247
+ * @param {MailTimeTask} task
4248
+ * @param {(t: MailTimeTask) => Promise<boolean>} write
4249
+ * @returns {Promise<boolean>}
4250
+ */
4251
+ async ___outcomeWrite(task, write) {
4252
+ if (await write(task)) {
4253
+ this.__uncertainRenewals.delete(task);
4254
+ return true;
4255
+ }
4256
+ const attempted = this.__uncertainRenewals.get(task);
4257
+ if (attempted === void 0) {
4258
+ return false;
4259
+ }
4260
+ this.__uncertainRenewals.delete(task);
4261
+ const lost = new Error('outcome write lost (renewal outcome uncertain or lease taken over)');
4262
+ if (attempted === null || this.__abortInFlight) {
4263
+ this.___reportWriteFailure(task, lost, 'complete');
4264
+ return false;
4265
+ }
4266
+ if (await write({ ...task, sendingAt: attempted })) {
4267
+ return true;
4268
+ }
4269
+ this.___reportWriteFailure(task, lost, 'complete');
4270
+ return false;
4271
+ }
4272
+
4026
4273
  /** @internal Serialize every policy checkpoint with this attempt's claim renewal. */
4027
4274
  async ___sendWithRecipientPolicies(task) {
4028
4275
  if (!task || task.isSent || task.isFailed || task.isCancelled || task.isSettled || this.__abortInFlight) return;
@@ -4037,9 +4284,12 @@ class MailTime {
4037
4284
  }
4038
4285
  Object.assign(task, fields);
4039
4286
  const lease = new RecipientPolicyLease({
4040
- task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals,
4287
+ task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals, sendingTimeout: this.sendingTimeout,
4041
4288
  shouldAbort: () => this.__abortInFlight,
4042
- report: (error) => logError('[recipientPolicies] lease persistence failed', error),
4289
+ report: (error, phase) => {
4290
+ if (phase === 'complete' || phase === 'checkpoint') this.___reportWriteFailure(task, error, phase);
4291
+ else if (!this.__abortInFlight) logError('[recipientPolicies] claim renewal write failed; renewals stop, the outcome write still runs', error);
4292
+ },
4043
4293
  });
4044
4294
  try {
4045
4295
  if (!lease.active) return;
@@ -4095,7 +4345,15 @@ class MailTime {
4095
4345
  context.recipients = context.recipients.filter((r) => eligible.has(r.address));
4096
4346
  await this.___attemptPolicyTransport(task, lease, compiled, context);
4097
4347
  } catch (error) {
4098
- if (lease.active) await this.___retryPolicyTask(task, lease, error, void 0, true, true);
4348
+ if (!lease.active) return;
4349
+ if (isAddressError(error)) {
4350
+ // Unparseable addresses fail identically on every attempt, so settle now instead of
4351
+ // spending the retry budget. The message names the field, never the address.
4352
+ if (!this.__abortInFlight) logError(`[recipientPolicies] task ${task.uuid} failed without retry:`, error.message);
4353
+ await this.___completePolicyTask(task, lease, error, void 0, true);
4354
+ } else {
4355
+ await this.___retryPolicyTask(task, lease, error, void 0, true, true);
4356
+ }
4099
4357
  } finally {
4100
4358
  await lease.stop();
4101
4359
  }
@@ -4104,7 +4362,7 @@ class MailTime {
4104
4362
  /** @internal Persist SMTP acceptance before awaiting rejection classification or observation. */
4105
4363
  async ___attemptPolicyTransport(task, lease, compiled, context) {
4106
4364
  const transport = this.transports[context.transport.index];
4107
- const outgoing = { ...compiled, envelope: context.envelope };
4365
+ const outgoing = { ...rewritePolicyHeaders(compiled), envelope: context.envelope };
4108
4366
  const { error, info } = await new Promise((resolve) => {
4109
4367
  let called = false;
4110
4368
  const done = (error, info) => {
@@ -4312,6 +4570,7 @@ class MailTime {
4312
4570
  task.tries = tries;
4313
4571
  task.isSending = true;
4314
4572
  task.sendingAt = sendingAt;
4573
+ this.__uncertainRenewals.delete(task);
4315
4574
 
4316
4575
  let transportIndex = task.transport;
4317
4576
  if (!this.___isHealthyTransport(transportIndex)) {
@@ -4365,20 +4624,14 @@ class MailTime {
4365
4624
  if (isFullyDelivered) {
4366
4625
  this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
4367
4626
 
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
- }
4627
+ const completed = await this.___outcomeWrite(task, (t) => (!this.keepHistory
4628
+ ? this.queue.remove(t, __leaseRemoveOpts(t))
4629
+ : this.queue.update(t, __withLease(t, {
4630
+ isSent: true,
4631
+ isSending: false,
4632
+ sendingAt: 0,
4633
+ mailOptions: task.mailOptions,
4634
+ }))));
4382
4635
 
4383
4636
  if (!completed) {
4384
4637
  this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
@@ -4396,21 +4649,15 @@ class MailTime {
4396
4649
  if (task.tries >= this.maxTries) {
4397
4650
  this.___finalizeRejected(task, info);
4398
4651
 
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
- }
4652
+ const finalized = await this.___outcomeWrite(task, (t) => (!this.keepHistory
4653
+ ? this.queue.remove(t, __leaseRemoveOpts(t))
4654
+ : this.queue.update(t, __withLease(t, {
4655
+ isSent: false,
4656
+ isFailed: true,
4657
+ isSending: false,
4658
+ sendingAt: 0,
4659
+ mailOptions: task.mailOptions,
4660
+ }))));
4414
4661
 
4415
4662
  if (!finalized) {
4416
4663
  this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
@@ -4435,19 +4682,19 @@ class MailTime {
4435
4682
  }
4436
4683
 
4437
4684
  const nextSendAt = Date.now() + this.retryDelay;
4438
- const released = await this.queue.update(task, __withLease(task, {
4685
+ const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
4439
4686
  isSending: false,
4440
4687
  sendingAt: 0,
4441
4688
  sendAt: nextSendAt,
4442
4689
  mailOptions: task.mailOptions,
4443
- }));
4690
+ })));
4444
4691
  if (!released) {
4445
4692
  this.__debug('[private send] lease lost before partial retry release', task.uuid);
4446
4693
  return;
4447
4694
  }
4448
4695
  this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
4449
4696
  } catch (completionError) {
4450
- logError('[private send] completion error after transport callback', completionError);
4697
+ this.___reportWriteFailure(task, completionError);
4451
4698
  } finally {
4452
4699
  resolve();
4453
4700
  }
@@ -4463,7 +4710,11 @@ class MailTime {
4463
4710
  return;
4464
4711
  }
4465
4712
  logError('Exception during runtime:', e);
4466
- await this.___handleError(task, e, {});
4713
+ try {
4714
+ await this.___handleError(task, e, {});
4715
+ } catch (completionError) {
4716
+ this.___reportWriteFailure(task, completionError);
4717
+ }
4467
4718
  }
4468
4719
  }
4469
4720
 
@@ -4525,51 +4776,163 @@ class MailTime {
4525
4776
  * @internal
4526
4777
  * @memberOf MailTime
4527
4778
  * @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.
4779
+ * @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
4780
  * @returns {Promise<void>}
4530
4781
  */
4531
4782
  async ___verifyTransports() {
4532
4783
  this.__debug('[private verifyTransports]');
4533
- const results = await Promise.all(this.transports.map(async (transport, index) => {
4784
+ await Promise.all(this.transports.map(async (transport, index) => {
4534
4785
  if (!transport || typeof transport.verify !== 'function') {
4535
- return { index, ok: true };
4786
+ return;
4787
+ }
4788
+ const outcome = await this.___verifyOne(transport, index);
4789
+ if (outcome === 'timeout' && !this.__isDestroyed) {
4790
+ console.warn(`[WARN] [mail-time] [verifyTransports] transport #${index} verify() gave no verdict within ${this.verifyTimeout}ms; keeping it usable, a late verdict still applies`);
4791
+ }
4792
+ }));
4793
+
4794
+ if (this.__unhealthyTransports.size === this.transports.length) {
4795
+ throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
4796
+ }
4797
+ }
4798
+
4799
+ /**
4800
+ * @internal
4801
+ * @memberOf MailTime
4802
+ * @name ___verifyOne
4803
+ * @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).
4804
+ * @param {MailTimeTransport} transport
4805
+ * @param {number} index
4806
+ * @returns {Promise<'ok' | 'fail' | 'timeout'>}
4807
+ */
4808
+ ___verifyOne(transport, index) {
4809
+ const gen = (this.__verifyGen[index] || 0) + 1;
4810
+ this.__verifyGen[index] = gen;
4811
+ return new Promise((resolve) => {
4812
+ let decided = false;
4813
+ let waiting = true;
4814
+ const stopWaiting = (outcome) => {
4815
+ if (!waiting) {
4816
+ return;
4817
+ }
4818
+ waiting = false;
4819
+ clearTimeout(timer);
4820
+ this.__probeCancels.delete(cancel);
4821
+ resolve(outcome);
4822
+ };
4823
+ const cancel = () => stopWaiting('timeout');
4824
+ const timer = setTimeout(cancel, this.verifyTimeout);
4825
+ if (typeof timer.unref === 'function') {
4826
+ timer.unref();
4536
4827
  }
4828
+ this.__probeCancels.add(cancel);
4829
+
4830
+ const decide = (error) => {
4831
+ if (decided) {
4832
+ return;
4833
+ }
4834
+ decided = true;
4835
+ this.___applyVerdict(index, gen, error);
4836
+ stopWaiting(error ? 'fail' : 'ok');
4837
+ };
4838
+
4537
4839
  try {
4538
- await Promise.resolve(transport.verify());
4539
- return { index, ok: true };
4840
+ const returned = transport.verify((error) => decide(error || void 0));
4841
+ if (returned && typeof returned.then === 'function') {
4842
+ returned.then(() => decide(), (error) => decide(error || new Error('[mail-time] [verifyTransports] verify() rejected')));
4843
+ } else if (returned === true || returned === false) {
4844
+ decide();
4845
+ }
4540
4846
  } catch (error) {
4541
- return { index, ok: false, error };
4847
+ decide(error || new Error('[mail-time] [verifyTransports] verify() threw'));
4542
4848
  }
4543
- }));
4849
+ });
4850
+ }
4544
4851
 
4545
- for (const r of results) {
4546
- if (r.ok) {
4547
- continue;
4852
+ /**
4853
+ * @internal
4854
+ * @memberOf MailTime
4855
+ * @name ___applyVerdict
4856
+ * @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.
4857
+ * @param {number} index
4858
+ * @param {number} gen
4859
+ * @param {unknown} [error]
4860
+ * @returns {void}
4861
+ */
4862
+ ___applyVerdict(index, gen, error) {
4863
+ if (this.__isDestroyed || this.__verifyGen[index] !== gen) {
4864
+ return;
4865
+ }
4866
+ const quarantined = this.__unhealthyTransports.has(index);
4867
+ if (!error) {
4868
+ if (!quarantined) {
4869
+ return;
4870
+ }
4871
+ this.__unhealthyTransports.delete(index);
4872
+ this.__reprobe.delete(index);
4873
+ if (this.strategy === 'backup' && index < this.transport) {
4874
+ this.transport = index;
4548
4875
  }
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' });
4876
+ console.log(`[mail-time] [verifyTransports] transport #${index} recovered; back in rotation`);
4877
+ return;
4552
4878
  }
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`);
4879
+ if (quarantined) {
4880
+ const st = this.__reprobe.get(index);
4881
+ if (st) {
4882
+ st.inFlight = false;
4883
+ st.count++;
4884
+ st.nextProbeAt = Date.now() + Math.min(REPROBE_BASE_MS * Math.pow(2, st.count), REPROBE_MAX_MS);
4885
+ this.__debug(`[private reprobe] transport #${index} failed, next probe at ${new Date(st.nextProbeAt)}`);
4886
+ }
4887
+ return;
4888
+ }
4889
+ this.__unhealthyTransports.add(index);
4890
+ this.__reprobe.set(index, { count: 0, nextProbeAt: Date.now() + REPROBE_BASE_MS, inFlight: false });
4891
+ if (this.transport === index && this.__unhealthyTransports.size < this.transports.length) {
4892
+ this.transport = this.___nextHealthyTransport(index);
4556
4893
  }
4894
+ logError(`[mail-time] [verifyTransports] transport #${index} failed verification`, error);
4895
+ callHook('onError', this.onError, error, null, { transportIndex: index, phase: 'verify' });
4896
+ }
4557
4897
 
4558
- if (this.__unhealthyTransports.has(this.transport)) {
4559
- this.transport = this.___nextHealthyTransport(this.transport);
4898
+ /**
4899
+ * @internal
4900
+ * @memberOf MailTime
4901
+ * @name ___maybeReprobe
4902
+ * @description Start one background `verify()` probe for a quarantined transport when its backoff has elapsed and no probe is in flight. Never awaited by the send path. Backoff starts at 60 s, doubles per failed probe and caps at 15 min. A probe that times out stays in flight until its verdict arrives, so at most one `verify()` is outstanding per transport. Failures only reach `__debug`. No probes for `type: 'client'`, `verifyTransports: false`, or after `destroy()`.
4903
+ * @param {number} index
4904
+ * @returns {void}
4905
+ */
4906
+ ___maybeReprobe(index) {
4907
+ if (this.__isDestroyed || this.type !== 'server' || !this.verifyTransports) {
4908
+ return;
4560
4909
  }
4910
+ const st = this.__reprobe.get(index);
4911
+ const transport = this.transports[index];
4912
+ if (!st || st.inFlight || st.nextProbeAt > Date.now() || !transport || typeof transport.verify !== 'function') {
4913
+ return;
4914
+ }
4915
+ st.inFlight = true;
4916
+ this.__debug(`[private reprobe] transport #${index}, attempt after ${st.count} failure(s)`);
4917
+ // The verdict, not the timeout, releases `inFlight` (see ___applyVerdict), so a hung
4918
+ // verify() never overlaps with a new probe on the same transport.
4919
+ this.___verifyOne(transport, index);
4561
4920
  }
4562
4921
 
4563
4922
  /**
4564
4923
  * @internal
4565
4924
  * @memberOf MailTime
4566
4925
  * @name ___isHealthyTransport
4567
- * @description Return true when the transport at `index` has not been marked unusable by verification.
4926
+ * @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
4927
  * @param {number} index
4569
4928
  * @returns {boolean}
4570
4929
  */
4571
4930
  ___isHealthyTransport(index) {
4572
- return !this.__unhealthyTransports.has(index);
4931
+ if (!this.__unhealthyTransports.has(index)) {
4932
+ return true;
4933
+ }
4934
+ this.___maybeReprobe(index);
4935
+ return false;
4573
4936
  }
4574
4937
 
4575
4938
  /**