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.js CHANGED
@@ -7,7 +7,7 @@ import { PostgresQueue } from './adapters/postgres.js';
7
7
  import { mailTimePreset, presets, presetNames } from './presets.js';
8
8
  import { debug, escapeHtml, logError, hasOwnProp, deepMerge, equals, isPlainObject, extractEmail, toAddressList, filterAddressField } from './helpers.js';
9
9
 
10
- import { validateRecipientPolicies, policyError, normalizePolicyAddress, preparePolicyEnvelope, evaluatePolicyPhase, mergePolicyResults, summarizePolicyTask } from './recipient-policy.js';
10
+ import { validateRecipientPolicies, validatePolicyMailOptions, policyError, isAddressError, normalizePolicyAddress, rewritePolicyHeaders, preparePolicyEnvelope, evaluatePolicyPhase, mergePolicyResults, summarizePolicyTask } from './recipient-policy.js';
11
11
  import { normalizeRejections } from './recipient-rejections.js';
12
12
  import { RecipientPolicyLease } from './recipient-policy-lease.js';
13
13
 
@@ -280,9 +280,21 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
280
280
  */
281
281
 
282
282
  /**
283
- * @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
283
+ * @typedef {{ failedWrites: number }} MailTimeDrainResult
284
284
  */
285
285
 
286
+ /**
287
+ * `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.
288
+ * @typedef {{ phase?: 'verify' | 'complete' | 'checkpoint', transportIndex?: number, attempt?: number, [key: string]: unknown }} MailTimeErrorDetails
289
+ */
290
+
291
+ /**
292
+ * @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
293
+ */
294
+
295
+ const REPROBE_BASE_MS = 60000;
296
+ const REPROBE_MAX_MS = 900000;
297
+
286
298
  /**
287
299
  * Class of MailTime.
288
300
  * With recipientPolicies, terminal callbacks receive grouped recipients and a complete summary after persistence.
@@ -385,6 +397,7 @@ class MailTime {
385
397
  this.__readyPromise = null;
386
398
  this.__schedulerTimer = null;
387
399
  this.__inFlight = new Set();
400
+ this.__failedWrites = 0;
388
401
  this.__pool = createPool(this.concurrency);
389
402
 
390
403
  this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
@@ -392,7 +405,12 @@ class MailTime {
392
405
  this.transports = Array.isArray(opts.transports) ? opts.transports : [];
393
406
  this.transport = 0;
394
407
  this.verifyTransports = opts.verifyTransports !== false;
408
+ this.verifyTimeout = (typeof opts.verifyTimeout === 'number' && opts.verifyTimeout > 0) ? Math.min(opts.verifyTimeout, 2147483647) : 30000;
395
409
  this.__unhealthyTransports = new Set();
410
+ this.__verifyGen = [];
411
+ this.__reprobe = new Map();
412
+ this.__probeCancels = new Set();
413
+ this.__uncertainRenewals = new WeakMap();
396
414
 
397
415
  if (typeof opts.from === 'string') {
398
416
  const fromStr = opts.from;
@@ -403,6 +421,16 @@ class MailTime {
403
421
  this.from = false;
404
422
  }
405
423
 
424
+ if (this.__recipientPolicies && this.type === 'server') {
425
+ // A transport `from` that the policy parser rejects would settle every rotated letter as
426
+ // failed; surface it here instead. A `from` callback is a runtime value and is not checked.
427
+ if (typeof opts.from === 'string') normalizePolicyAddress(opts.from, 'from');
428
+ this.transports.forEach((transport, i) => {
429
+ const from = MailTime.transportFrom(transport);
430
+ if (from !== void 0) normalizePolicyAddress(from, `transports[${i}].from`);
431
+ });
432
+ }
433
+
406
434
  this.queue.mailTimeInstance = this;
407
435
 
408
436
  /** @type {string} */
@@ -575,6 +603,8 @@ class MailTime {
575
603
 
576
604
  this.__isDestroyed = true;
577
605
  this.__isPaused = false;
606
+ for (const cancel of [...this.__probeCancels]) cancel();
607
+ this.__reprobe.clear();
578
608
  // Queued jobs never started SMTP; dropping them lets a scan blocked on a
579
609
  // pool slot return, so JoSk shutdown does not wait for in-flight sends.
580
610
  this.__pool.cancelQueued();
@@ -610,14 +640,34 @@ class MailTime {
610
640
  * @async
611
641
  * @memberOf MailTime
612
642
  * @name drain
613
- * @description Wait for all in-flight email send attempts to settle
614
- * @returns {Promise<void>}
643
+ * @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.
644
+ * @returns {Promise<MailTimeDrainResult>}
615
645
  */
616
646
  async drain() {
617
647
  this.__debug('[drain]');
618
648
  if (this.__pool) {
619
649
  await this.__pool.drain();
620
650
  }
651
+ return { failedWrites: this.__failedWrites };
652
+ }
653
+
654
+ /**
655
+ * @internal
656
+ * @memberOf MailTime
657
+ * @name ___reportWriteFailure
658
+ * @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.
659
+ * @param {MailTimeTask} task
660
+ * @param {unknown} error
661
+ * @param {'complete' | 'checkpoint'} [phase]
662
+ * @returns {void}
663
+ */
664
+ ___reportWriteFailure(task, error, phase = 'complete') {
665
+ this.__failedWrites++;
666
+ if (this.__abortInFlight) {
667
+ return;
668
+ }
669
+ logError(`[private send] completion error, ${phase} write failed; row ${task?.uuid} may stay claimed and be re-sent after sendingTimeout`, error);
670
+ callHook('onError', this.onError, error, task || null, { phase, attempt: task?.tries, transportIndex: task?.transport });
621
671
  }
622
672
 
623
673
  /**
@@ -722,6 +772,10 @@ class MailTime {
722
772
  throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
723
773
  }
724
774
 
775
+ if (this.__recipientPolicies) {
776
+ validatePolicyMailOptions(mailOptions);
777
+ }
778
+
725
779
  if (this.concatEmails) {
726
780
  sendAt = sendAt + this.concatDelay;
727
781
  const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
@@ -803,21 +857,15 @@ class MailTime {
803
857
  if (task.tries >= this.maxTries) {
804
858
  this.___finalizeRejected(task, info);
805
859
 
806
- const leaseRemove = __leaseRemoveOpts(task);
807
- const leaseUpdate = __withLease(task, {
808
- isSent: false,
809
- isFailed: true,
810
- isSending: false,
811
- sendingAt: 0,
812
- mailOptions: task.mailOptions,
813
- });
814
-
815
- let finalized = false;
816
- if (!this.keepHistory) {
817
- finalized = await this.queue.remove(task, leaseRemove);
818
- } else {
819
- finalized = await this.queue.update(task, leaseUpdate);
820
- }
860
+ const finalized = await this.___outcomeWrite(task, (t) => (!this.keepHistory
861
+ ? this.queue.remove(t, __leaseRemoveOpts(t))
862
+ : this.queue.update(t, __withLease(t, {
863
+ isSent: false,
864
+ isFailed: true,
865
+ isSending: false,
866
+ sendingAt: 0,
867
+ mailOptions: task.mailOptions,
868
+ }))));
821
869
 
822
870
  if (!finalized) {
823
871
  this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
@@ -843,12 +891,12 @@ class MailTime {
843
891
  transportIndex = this.___nextHealthyTransport(transportIndex);
844
892
  }
845
893
 
846
- const released = await this.queue.update(task, __withLease(task, {
894
+ const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
847
895
  isSending: false,
848
896
  sendingAt: 0,
849
897
  sendAt: Date.now() + this.retryDelay,
850
898
  transport: transportIndex,
851
- }));
899
+ })));
852
900
 
853
901
  if (!released) {
854
902
  this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
@@ -1250,6 +1298,10 @@ class MailTime {
1250
1298
  }));
1251
1299
  } catch (renewError) {
1252
1300
  logError('[private renewClaim] storage error during claim renewal', renewError);
1301
+ // Storage may have applied the write before the driver failed; remember the stamp
1302
+ // so the outcome write can retry with it (see ___outcomeWrite).
1303
+ // A stale claim is remembered as null: the lost outcome write is reported, never retried.
1304
+ this.__uncertainRenewals.set(task, task.sendingAt > renewedAt - this.sendingTimeout ? renewedAt : null);
1253
1305
  halt();
1254
1306
  return;
1255
1307
  }
@@ -1280,6 +1332,38 @@ class MailTime {
1280
1332
  return { stop };
1281
1333
  }
1282
1334
 
1335
+ /**
1336
+ * @async
1337
+ * @internal
1338
+ * @memberOf MailTime
1339
+ * @name ___outcomeWrite
1340
+ * @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.
1341
+ * @param {MailTimeTask} task
1342
+ * @param {(t: MailTimeTask) => Promise<boolean>} write
1343
+ * @returns {Promise<boolean>}
1344
+ */
1345
+ async ___outcomeWrite(task, write) {
1346
+ if (await write(task)) {
1347
+ this.__uncertainRenewals.delete(task);
1348
+ return true;
1349
+ }
1350
+ const attempted = this.__uncertainRenewals.get(task);
1351
+ if (attempted === void 0) {
1352
+ return false;
1353
+ }
1354
+ this.__uncertainRenewals.delete(task);
1355
+ const lost = new Error('outcome write lost (renewal outcome uncertain or lease taken over)');
1356
+ if (attempted === null || this.__abortInFlight) {
1357
+ this.___reportWriteFailure(task, lost, 'complete');
1358
+ return false;
1359
+ }
1360
+ if (await write({ ...task, sendingAt: attempted })) {
1361
+ return true;
1362
+ }
1363
+ this.___reportWriteFailure(task, lost, 'complete');
1364
+ return false;
1365
+ }
1366
+
1283
1367
  /** @internal Serialize every policy checkpoint with this attempt's claim renewal. */
1284
1368
  async ___sendWithRecipientPolicies(task) {
1285
1369
  if (!task || task.isSent || task.isFailed || task.isCancelled || task.isSettled || this.__abortInFlight) return;
@@ -1294,9 +1378,12 @@ class MailTime {
1294
1378
  }
1295
1379
  Object.assign(task, fields);
1296
1380
  const lease = new RecipientPolicyLease({
1297
- task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals,
1381
+ task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals, sendingTimeout: this.sendingTimeout,
1298
1382
  shouldAbort: () => this.__abortInFlight,
1299
- report: (error) => logError('[recipientPolicies] lease persistence failed', error),
1383
+ report: (error, phase) => {
1384
+ if (phase === 'complete' || phase === 'checkpoint') this.___reportWriteFailure(task, error, phase);
1385
+ else if (!this.__abortInFlight) logError('[recipientPolicies] claim renewal write failed; renewals stop, the outcome write still runs', error);
1386
+ },
1300
1387
  });
1301
1388
  try {
1302
1389
  if (!lease.active) return;
@@ -1352,7 +1439,15 @@ class MailTime {
1352
1439
  context.recipients = context.recipients.filter((r) => eligible.has(r.address));
1353
1440
  await this.___attemptPolicyTransport(task, lease, compiled, context);
1354
1441
  } catch (error) {
1355
- if (lease.active) await this.___retryPolicyTask(task, lease, error, void 0, true, true);
1442
+ if (!lease.active) return;
1443
+ if (isAddressError(error)) {
1444
+ // Unparseable addresses fail identically on every attempt, so settle now instead of
1445
+ // spending the retry budget. The message names the field, never the address.
1446
+ if (!this.__abortInFlight) logError(`[recipientPolicies] task ${task.uuid} failed without retry:`, error.message);
1447
+ await this.___completePolicyTask(task, lease, error, void 0, true);
1448
+ } else {
1449
+ await this.___retryPolicyTask(task, lease, error, void 0, true, true);
1450
+ }
1356
1451
  } finally {
1357
1452
  await lease.stop();
1358
1453
  }
@@ -1361,7 +1456,7 @@ class MailTime {
1361
1456
  /** @internal Persist SMTP acceptance before awaiting rejection classification or observation. */
1362
1457
  async ___attemptPolicyTransport(task, lease, compiled, context) {
1363
1458
  const transport = this.transports[context.transport.index];
1364
- const outgoing = { ...compiled, envelope: context.envelope };
1459
+ const outgoing = { ...rewritePolicyHeaders(compiled), envelope: context.envelope };
1365
1460
  const { error, info } = await new Promise((resolve) => {
1366
1461
  let called = false;
1367
1462
  const done = (error, info) => {
@@ -1569,6 +1664,7 @@ class MailTime {
1569
1664
  task.tries = tries;
1570
1665
  task.isSending = true;
1571
1666
  task.sendingAt = sendingAt;
1667
+ this.__uncertainRenewals.delete(task);
1572
1668
 
1573
1669
  let transportIndex = task.transport;
1574
1670
  if (!this.___isHealthyTransport(transportIndex)) {
@@ -1622,20 +1718,14 @@ class MailTime {
1622
1718
  if (isFullyDelivered) {
1623
1719
  this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
1624
1720
 
1625
- const leaseRemove = __leaseRemoveOpts(task);
1626
- const leaseUpdate = __withLease(task, {
1627
- isSent: true,
1628
- isSending: false,
1629
- sendingAt: 0,
1630
- mailOptions: task.mailOptions,
1631
- });
1632
-
1633
- let completed = false;
1634
- if (!this.keepHistory) {
1635
- completed = await this.queue.remove(task, leaseRemove);
1636
- } else {
1637
- completed = await this.queue.update(task, leaseUpdate);
1638
- }
1721
+ const completed = await this.___outcomeWrite(task, (t) => (!this.keepHistory
1722
+ ? this.queue.remove(t, __leaseRemoveOpts(t))
1723
+ : this.queue.update(t, __withLease(t, {
1724
+ isSent: true,
1725
+ isSending: false,
1726
+ sendingAt: 0,
1727
+ mailOptions: task.mailOptions,
1728
+ }))));
1639
1729
 
1640
1730
  if (!completed) {
1641
1731
  this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
@@ -1653,21 +1743,15 @@ class MailTime {
1653
1743
  if (task.tries >= this.maxTries) {
1654
1744
  this.___finalizeRejected(task, info);
1655
1745
 
1656
- const leaseRemove = __leaseRemoveOpts(task);
1657
- const leaseUpdate = __withLease(task, {
1658
- isSent: false,
1659
- isFailed: true,
1660
- isSending: false,
1661
- sendingAt: 0,
1662
- mailOptions: task.mailOptions,
1663
- });
1664
-
1665
- let finalized = false;
1666
- if (!this.keepHistory) {
1667
- finalized = await this.queue.remove(task, leaseRemove);
1668
- } else {
1669
- finalized = await this.queue.update(task, leaseUpdate);
1670
- }
1746
+ const finalized = await this.___outcomeWrite(task, (t) => (!this.keepHistory
1747
+ ? this.queue.remove(t, __leaseRemoveOpts(t))
1748
+ : this.queue.update(t, __withLease(t, {
1749
+ isSent: false,
1750
+ isFailed: true,
1751
+ isSending: false,
1752
+ sendingAt: 0,
1753
+ mailOptions: task.mailOptions,
1754
+ }))));
1671
1755
 
1672
1756
  if (!finalized) {
1673
1757
  this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
@@ -1692,19 +1776,19 @@ class MailTime {
1692
1776
  }
1693
1777
 
1694
1778
  const nextSendAt = Date.now() + this.retryDelay;
1695
- const released = await this.queue.update(task, __withLease(task, {
1779
+ const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
1696
1780
  isSending: false,
1697
1781
  sendingAt: 0,
1698
1782
  sendAt: nextSendAt,
1699
1783
  mailOptions: task.mailOptions,
1700
- }));
1784
+ })));
1701
1785
  if (!released) {
1702
1786
  this.__debug('[private send] lease lost before partial retry release', task.uuid);
1703
1787
  return;
1704
1788
  }
1705
1789
  this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
1706
1790
  } catch (completionError) {
1707
- logError('[private send] completion error after transport callback', completionError);
1791
+ this.___reportWriteFailure(task, completionError);
1708
1792
  } finally {
1709
1793
  resolve();
1710
1794
  }
@@ -1720,7 +1804,11 @@ class MailTime {
1720
1804
  return;
1721
1805
  }
1722
1806
  logError('Exception during runtime:', e);
1723
- await this.___handleError(task, e, {});
1807
+ try {
1808
+ await this.___handleError(task, e, {});
1809
+ } catch (completionError) {
1810
+ this.___reportWriteFailure(task, completionError);
1811
+ }
1724
1812
  }
1725
1813
  }
1726
1814
 
@@ -1782,51 +1870,163 @@ class MailTime {
1782
1870
  * @internal
1783
1871
  * @memberOf MailTime
1784
1872
  * @name ___verifyTransports
1785
- * @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.
1873
+ * @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.
1786
1874
  * @returns {Promise<void>}
1787
1875
  */
1788
1876
  async ___verifyTransports() {
1789
1877
  this.__debug('[private verifyTransports]');
1790
- const results = await Promise.all(this.transports.map(async (transport, index) => {
1878
+ await Promise.all(this.transports.map(async (transport, index) => {
1791
1879
  if (!transport || typeof transport.verify !== 'function') {
1792
- return { index, ok: true };
1880
+ return;
1881
+ }
1882
+ const outcome = await this.___verifyOne(transport, index);
1883
+ if (outcome === 'timeout' && !this.__isDestroyed) {
1884
+ console.warn(`[WARN] [mail-time] [verifyTransports] transport #${index} verify() gave no verdict within ${this.verifyTimeout}ms; keeping it usable, a late verdict still applies`);
1793
1885
  }
1886
+ }));
1887
+
1888
+ if (this.__unhealthyTransports.size === this.transports.length) {
1889
+ throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
1890
+ }
1891
+ }
1892
+
1893
+ /**
1894
+ * @internal
1895
+ * @memberOf MailTime
1896
+ * @name ___verifyOne
1897
+ * @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).
1898
+ * @param {MailTimeTransport} transport
1899
+ * @param {number} index
1900
+ * @returns {Promise<'ok' | 'fail' | 'timeout'>}
1901
+ */
1902
+ ___verifyOne(transport, index) {
1903
+ const gen = (this.__verifyGen[index] || 0) + 1;
1904
+ this.__verifyGen[index] = gen;
1905
+ return new Promise((resolve) => {
1906
+ let decided = false;
1907
+ let waiting = true;
1908
+ const stopWaiting = (outcome) => {
1909
+ if (!waiting) {
1910
+ return;
1911
+ }
1912
+ waiting = false;
1913
+ clearTimeout(timer);
1914
+ this.__probeCancels.delete(cancel);
1915
+ resolve(outcome);
1916
+ };
1917
+ const cancel = () => stopWaiting('timeout');
1918
+ const timer = setTimeout(cancel, this.verifyTimeout);
1919
+ if (typeof timer.unref === 'function') {
1920
+ timer.unref();
1921
+ }
1922
+ this.__probeCancels.add(cancel);
1923
+
1924
+ const decide = (error) => {
1925
+ if (decided) {
1926
+ return;
1927
+ }
1928
+ decided = true;
1929
+ this.___applyVerdict(index, gen, error);
1930
+ stopWaiting(error ? 'fail' : 'ok');
1931
+ };
1932
+
1794
1933
  try {
1795
- await Promise.resolve(transport.verify());
1796
- return { index, ok: true };
1934
+ const returned = transport.verify((error) => decide(error || void 0));
1935
+ if (returned && typeof returned.then === 'function') {
1936
+ returned.then(() => decide(), (error) => decide(error || new Error('[mail-time] [verifyTransports] verify() rejected')));
1937
+ } else if (returned === true || returned === false) {
1938
+ decide();
1939
+ }
1797
1940
  } catch (error) {
1798
- return { index, ok: false, error };
1941
+ decide(error || new Error('[mail-time] [verifyTransports] verify() threw'));
1799
1942
  }
1800
- }));
1943
+ });
1944
+ }
1801
1945
 
1802
- for (const r of results) {
1803
- if (r.ok) {
1804
- continue;
1946
+ /**
1947
+ * @internal
1948
+ * @memberOf MailTime
1949
+ * @name ___applyVerdict
1950
+ * @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.
1951
+ * @param {number} index
1952
+ * @param {number} gen
1953
+ * @param {unknown} [error]
1954
+ * @returns {void}
1955
+ */
1956
+ ___applyVerdict(index, gen, error) {
1957
+ if (this.__isDestroyed || this.__verifyGen[index] !== gen) {
1958
+ return;
1959
+ }
1960
+ const quarantined = this.__unhealthyTransports.has(index);
1961
+ if (!error) {
1962
+ if (!quarantined) {
1963
+ return;
1805
1964
  }
1806
- this.__unhealthyTransports.add(r.index);
1807
- logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
1808
- callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
1965
+ this.__unhealthyTransports.delete(index);
1966
+ this.__reprobe.delete(index);
1967
+ if (this.strategy === 'backup' && index < this.transport) {
1968
+ this.transport = index;
1969
+ }
1970
+ console.log(`[mail-time] [verifyTransports] transport #${index} recovered; back in rotation`);
1971
+ return;
1809
1972
  }
1810
-
1811
- if (this.__unhealthyTransports.size === this.transports.length) {
1812
- throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
1973
+ if (quarantined) {
1974
+ const st = this.__reprobe.get(index);
1975
+ if (st) {
1976
+ st.inFlight = false;
1977
+ st.count++;
1978
+ st.nextProbeAt = Date.now() + Math.min(REPROBE_BASE_MS * Math.pow(2, st.count), REPROBE_MAX_MS);
1979
+ this.__debug(`[private reprobe] transport #${index} failed, next probe at ${new Date(st.nextProbeAt)}`);
1980
+ }
1981
+ return;
1982
+ }
1983
+ this.__unhealthyTransports.add(index);
1984
+ this.__reprobe.set(index, { count: 0, nextProbeAt: Date.now() + REPROBE_BASE_MS, inFlight: false });
1985
+ if (this.transport === index && this.__unhealthyTransports.size < this.transports.length) {
1986
+ this.transport = this.___nextHealthyTransport(index);
1813
1987
  }
1988
+ logError(`[mail-time] [verifyTransports] transport #${index} failed verification`, error);
1989
+ callHook('onError', this.onError, error, null, { transportIndex: index, phase: 'verify' });
1990
+ }
1814
1991
 
1815
- if (this.__unhealthyTransports.has(this.transport)) {
1816
- this.transport = this.___nextHealthyTransport(this.transport);
1992
+ /**
1993
+ * @internal
1994
+ * @memberOf MailTime
1995
+ * @name ___maybeReprobe
1996
+ * @description Start one background `verify()` probe for a quarantined transport when its backoff has elapsed and no probe is in flight. Never awaited by the send path. Backoff starts at 60 s, doubles per failed probe and caps at 15 min. A probe that times out stays in flight until its verdict arrives, so at most one `verify()` is outstanding per transport. Failures only reach `__debug`. No probes for `type: 'client'`, `verifyTransports: false`, or after `destroy()`.
1997
+ * @param {number} index
1998
+ * @returns {void}
1999
+ */
2000
+ ___maybeReprobe(index) {
2001
+ if (this.__isDestroyed || this.type !== 'server' || !this.verifyTransports) {
2002
+ return;
1817
2003
  }
2004
+ const st = this.__reprobe.get(index);
2005
+ const transport = this.transports[index];
2006
+ if (!st || st.inFlight || st.nextProbeAt > Date.now() || !transport || typeof transport.verify !== 'function') {
2007
+ return;
2008
+ }
2009
+ st.inFlight = true;
2010
+ this.__debug(`[private reprobe] transport #${index}, attempt after ${st.count} failure(s)`);
2011
+ // The verdict, not the timeout, releases `inFlight` (see ___applyVerdict), so a hung
2012
+ // verify() never overlaps with a new probe on the same transport.
2013
+ this.___verifyOne(transport, index);
1818
2014
  }
1819
2015
 
1820
2016
  /**
1821
2017
  * @internal
1822
2018
  * @memberOf MailTime
1823
2019
  * @name ___isHealthyTransport
1824
- * @description Return true when the transport at `index` has not been marked unusable by verification.
2020
+ * @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.
1825
2021
  * @param {number} index
1826
2022
  * @returns {boolean}
1827
2023
  */
1828
2024
  ___isHealthyTransport(index) {
1829
- return !this.__unhealthyTransports.has(index);
2025
+ if (!this.__unhealthyTransports.has(index)) {
2026
+ return true;
2027
+ }
2028
+ this.___maybeReprobe(index);
2029
+ return false;
1830
2030
  }
1831
2031
 
1832
2032
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mail-time",
3
- "version": "5.2.1",
3
+ "version": "5.3.0",
4
4
  "description": "📮 Email queue extending nodemailer with multi-SMTP transports and horizontally scaled apps support",
5
5
  "main": "index.js",
6
6
  "types": "./index.d.ts",
@@ -134,7 +134,7 @@
134
134
  "author": "dr.dimitru (https://veliovgroup.com)",
135
135
  "license": "BSD-3-Clause",
136
136
  "engines": {
137
- "node": ">=20.9.0",
137
+ "node": ">=14.19.3",
138
138
  "bun": ">=1.1.0"
139
139
  },
140
140
  "meteor": {
@@ -149,7 +149,7 @@
149
149
  }
150
150
  },
151
151
  "dependencies": {
152
- "josk": "^6.4.0"
152
+ "josk": "^6.5.0"
153
153
  },
154
154
  "devDependencies": {
155
155
  "@jest/globals": "^30.4.1",
@@ -159,7 +159,7 @@
159
159
  "jest": "^30.4.2",
160
160
  "mocha": "^11.8.0",
161
161
  "mongodb": "^7.2.0",
162
- "nodemailer": "^9.0.5",
162
+ "nodemailer": "^10.0.9",
163
163
  "pg": "^8.21.0",
164
164
  "redis": "^5.12.1",
165
165
  "rollup": "^4.60.4",