mail-time 5.2.0 → 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
@@ -524,7 +524,7 @@ class MongoQueue {
524
524
  }
525
525
 
526
526
  try {
527
- while (await cursor.hasNext()) {
527
+ while (!this.mailTimeInstance.___isStopped && await cursor.hasNext()) {
528
528
  await this.mailTimeInstance.___dispatch(await cursor.next());
529
529
  }
530
530
  } finally {
@@ -1214,6 +1214,9 @@ class RedisQueue {
1214
1214
  return;
1215
1215
  }
1216
1216
  for (const value of candidates) {
1217
+ if (this.mailTimeInstance.___isStopped) {
1218
+ break;
1219
+ }
1217
1220
  const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
1218
1221
  if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
1219
1222
  await this.mailTimeInstance.___dispatch(candidate);
@@ -1234,6 +1237,9 @@ class RedisQueue {
1234
1237
  for await (const cursorValue of cursor) {
1235
1238
  const sendatKeys = Array.isArray(cursorValue) ? cursorValue : [cursorValue];
1236
1239
  for (const sendatKey of sendatKeys) {
1240
+ if (this.mailTimeInstance.___isStopped) {
1241
+ break outer;
1242
+ }
1237
1243
  const raw = await this.client.get(sendatKey);
1238
1244
  if (raw === null || parseInt(raw, 10) > now) {
1239
1245
  continue;
@@ -1964,6 +1970,9 @@ class PostgresQueue {
1964
1970
  LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
1965
1971
 
1966
1972
  for (const row of res.rows || []) {
1973
+ if (this.mailTimeInstance.___isStopped) {
1974
+ break;
1975
+ }
1967
1976
  await this.mailTimeInstance.___dispatch(normalizeRow(row));
1968
1977
  }
1969
1978
  } catch (iterateError) {
@@ -2305,12 +2314,12 @@ const PRESETS = Object.freeze({
2305
2314
  transactional: Object.freeze({
2306
2315
  concatEmails: false,
2307
2316
  retries: 30,
2308
- retryDelay: 10_000,
2317
+ retryDelay: 10000,
2309
2318
  mode: 'batch',
2310
2319
  concurrency: 1,
2311
2320
  onError: defaultPresetOnError,
2312
2321
  josk: Object.freeze({
2313
- zombieTime: 120_000,
2322
+ zombieTime: 120000,
2314
2323
  }),
2315
2324
  }),
2316
2325
  otp: Object.freeze({
@@ -2318,52 +2327,52 @@ const PRESETS = Object.freeze({
2318
2327
  retries: 5,
2319
2328
  retryDelay: 2000,
2320
2329
  revolvingInterval: 1024,
2321
- sendingTimeout: 120_000,
2330
+ sendingTimeout: 120000,
2322
2331
  mode: 'batch',
2323
2332
  concurrency: 4,
2324
2333
  onError: defaultPresetOnError,
2325
2334
  josk: Object.freeze({
2326
2335
  minRevolvingDelay: 256,
2327
2336
  maxRevolvingDelay: 1024,
2328
- zombieTime: 60_000,
2337
+ zombieTime: 60000,
2329
2338
  }),
2330
2339
  }),
2331
2340
  newsletter: Object.freeze({
2332
2341
  concatEmails: true,
2333
- concatDelay: 5 * 60_000,
2342
+ concatDelay: 5 * 60000,
2334
2343
  concatSubject: 'Your updates',
2335
2344
  retries: 5,
2336
- retryDelay: 60_000,
2337
- sendingTimeout: 600_000,
2345
+ retryDelay: 60000,
2346
+ sendingTimeout: 600000,
2338
2347
  mode: 'batch',
2339
2348
  concurrency: 2,
2340
2349
  onError: defaultPresetOnError,
2341
2350
  josk: Object.freeze({
2342
- zombieTime: 300_000,
2351
+ zombieTime: 300000,
2343
2352
  }),
2344
2353
  }),
2345
2354
  marketing: Object.freeze({
2346
2355
  concatEmails: false,
2347
2356
  retries: 10,
2348
- retryDelay: 30_000,
2357
+ retryDelay: 30000,
2349
2358
  mode: 'batch',
2350
2359
  concurrency: 5,
2351
2360
  onError: defaultPresetOnError,
2352
2361
  josk: Object.freeze({
2353
- zombieTime: 180_000,
2362
+ zombieTime: 180000,
2354
2363
  }),
2355
2364
  }),
2356
2365
  notifications: Object.freeze({
2357
2366
  concatEmails: true,
2358
- concatDelay: 60_000,
2367
+ concatDelay: 60000,
2359
2368
  concatSubject: 'New activity',
2360
2369
  retries: 8,
2361
- retryDelay: 30_000,
2370
+ retryDelay: 30000,
2362
2371
  mode: 'batch',
2363
2372
  concurrency: 3,
2364
2373
  onError: defaultPresetOnError,
2365
2374
  josk: Object.freeze({
2366
- zombieTime: 180_000,
2375
+ zombieTime: 180000,
2367
2376
  }),
2368
2377
  }),
2369
2378
  alerts: Object.freeze({
@@ -2371,14 +2380,14 @@ const PRESETS = Object.freeze({
2371
2380
  retries: 20,
2372
2381
  retryDelay: 5000,
2373
2382
  revolvingInterval: 1024,
2374
- sendingTimeout: 120_000,
2383
+ sendingTimeout: 120000,
2375
2384
  mode: 'batch',
2376
2385
  concurrency: 2,
2377
2386
  onError: defaultPresetOnError,
2378
2387
  josk: Object.freeze({
2379
2388
  minRevolvingDelay: 256,
2380
2389
  maxRevolvingDelay: 1024,
2381
- zombieTime: 60_000,
2390
+ zombieTime: 60000,
2382
2391
  }),
2383
2392
  }),
2384
2393
  });
@@ -2437,29 +2446,113 @@ const mailTimePreset = (name, overrides) => {
2437
2446
 
2438
2447
  const policyError = (message) => new Error(`[mail-time] [recipientPolicies] ${message}`);
2439
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;
2440
2452
 
2441
- const normalizePolicyAddress = (value) => {
2442
- const input = typeof value === 'string' ? value : (!Array.isArray(value) && value?.address);
2443
- if (typeof input !== 'string' || /[\r\n]/u.test(input)) {
2444
- 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
+ }
2445
2515
  }
2446
- let address = input.trim();
2447
- if (address.includes('<') || address.includes('>')) {
2448
- const match = address.match(/^[^<>,;:"\r\n]*<([^<>]+)>$/u);
2449
- if (!match) throw policyError('use a simple explicit envelope.to');
2450
- 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 };
2451
2521
  }
2452
- if (!SIMPLE_MAILBOX.test(address)) throw policyError('use a simple explicit envelope.to');
2453
- 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);
2454
2545
  };
2455
2546
 
2456
2547
  const preparePolicyEnvelope = (compiled, previousResults = []) => {
2457
2548
  const recipients = new Map();
2458
2549
  const explicit = compiled.envelope && hasOwnProp(compiled.envelope, 'to');
2459
- const add = (value, source, authoritative) => {
2460
- 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++) {
2461
2554
  let address;
2462
- try { address = normalizePolicyAddress(entry); }
2555
+ try { address = normalizePolicyAddress(entries[i], list ? `${label}[${i}]` : label); }
2463
2556
  catch (error) { if (authoritative) throw error; else continue; }
2464
2557
  if (!recipients.has(address)) {
2465
2558
  if (!authoritative) continue;
@@ -2469,7 +2562,7 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
2469
2562
  if (!sources.includes(source)) sources.push(source);
2470
2563
  }
2471
2564
  };
2472
- if (explicit) add(compiled.envelope.to, 'envelope', true);
2565
+ if (explicit) add(compiled.envelope.to, 'envelope', true, 'envelope.to');
2473
2566
  for (const source of ['to', 'cc', 'bcc']) {
2474
2567
  if (hasOwnProp(compiled, source) && compiled[source] !== void 0) add(compiled[source], source, !explicit);
2475
2568
  }
@@ -2479,14 +2572,61 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
2479
2572
  }
2480
2573
  const envelope = { to: [...recipients.keys()] };
2481
2574
  if (compiled.envelope && hasOwnProp(compiled.envelope, 'from')) {
2482
- envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from);
2575
+ envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from, 'envelope.from');
2483
2576
  } else {
2484
- const from = compiled.from || compiled.sender || compiled.replyTo;
2485
- 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);
2486
2580
  }
2487
2581
  return { envelope, recipients: [...recipients.values()] };
2488
2582
  };
2489
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
+
2490
2630
  const validateRecipientPolicies = (value, queue) => {
2491
2631
  if (value === void 0) return null;
2492
2632
  if (!Array.isArray(value) || !value.length) throw policyError('recipientPolicies must be a nonempty array');
@@ -2523,7 +2663,7 @@ const validatePolicyResult = (raw, hook, context) => {
2523
2663
  const decisions = new Map();
2524
2664
  for (const item of raw.decisions) {
2525
2665
  if (!isPlainObject(item)) throw policyError('decision must be an object');
2526
- const address = normalizePolicyAddress(item.address);
2666
+ const address = normalizePolicyAddress(item.address, 'decision.address');
2527
2667
  if (!batch.has(address)) throw policyError('decision address is outside the input batch');
2528
2668
  const allowed = hook === 'beforeSend' ? item.status === 'suppressed' : (item.status === 'retry' || item.status === 'rejected');
2529
2669
  if (!allowed) throw policyError('invalid decision status for this phase');
@@ -2657,7 +2797,11 @@ const normalizeRejections = (error, info, transport) => {
2657
2797
  };
2658
2798
 
2659
2799
  class RecipientPolicyLease {
2660
- 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;
2661
2805
  this.__task = task;
2662
2806
  this.__queue = queue;
2663
2807
  this.__shouldAbort = shouldAbort;
@@ -2665,6 +2809,8 @@ class RecipientPolicyLease {
2665
2809
  this.__closed = false;
2666
2810
  this.__finishing = false;
2667
2811
  this.__tail = Promise.resolve();
2812
+ this.__renewUncertain = false;
2813
+ this.__attemptedAt = 0;
2668
2814
  this.__timer = null;
2669
2815
  let pending = false;
2670
2816
  let renewals = 0;
@@ -2679,6 +2825,8 @@ class RecipientPolicyLease {
2679
2825
  renewals++;
2680
2826
  this.__enqueue(async (guard) => {
2681
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;
2682
2830
  return await this.__write(fields, guard);
2683
2831
  }).finally(() => { pending = false; });
2684
2832
  }, interval);
@@ -2698,17 +2846,41 @@ class RecipientPolicyLease {
2698
2846
  this.__closed = true;
2699
2847
  }
2700
2848
 
2701
- __enqueue(operation) {
2849
+ __enqueue(operation, phase = 'renew') {
2702
2850
  const pending = this.__tail.then(async () => {
2703
2851
  if (!this.active) return false;
2704
2852
  const guard = { leaseTries: this.__task.tries, leaseSendingAt: this.__task.sendingAt };
2705
2853
  try {
2706
- const ok = await operation(guard);
2707
- 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
+ }
2708
2877
  return ok;
2709
2878
  } catch (error) {
2710
- this.__halt();
2711
- 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);
2712
2884
  return false;
2713
2885
  }
2714
2886
  });
@@ -2724,7 +2896,7 @@ class RecipientPolicyLease {
2724
2896
 
2725
2897
  update(fields) {
2726
2898
  if (this.__finishing) return Promise.resolve(false);
2727
- return this.__enqueue((guard) => this.__write(fields, guard));
2899
+ return this.__enqueue((guard) => this.__write(fields, guard), 'checkpoint');
2728
2900
  }
2729
2901
 
2730
2902
  finish(fields, remove) {
@@ -2736,7 +2908,7 @@ class RecipientPolicyLease {
2736
2908
  if (ok) Object.assign(this.__task, fields);
2737
2909
  this.__halt();
2738
2910
  return ok;
2739
- });
2911
+ }, 'complete');
2740
2912
  }
2741
2913
 
2742
2914
  async stop() {
@@ -2813,11 +2985,19 @@ const createPool = (concurrency) => {
2813
2985
  let active = 0;
2814
2986
  let drainResolvers = [];
2815
2987
 
2988
+ const settleDrain = () => {
2989
+ if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
2990
+ const resolvers = drainResolvers;
2991
+ drainResolvers = [];
2992
+ for (const r of resolvers) r();
2993
+ }
2994
+ };
2995
+
2816
2996
  const tryStart = () => {
2817
2997
  while (active < limit && queue.length > 0) {
2818
2998
  const job = queue.shift();
2819
2999
  active++;
2820
- job.resolveSlot();
3000
+ job.resolveSlot(true);
2821
3001
  Promise.resolve()
2822
3002
  .then(job.fn)
2823
3003
  .catch((poolError) => {
@@ -2825,11 +3005,7 @@ const createPool = (concurrency) => {
2825
3005
  })
2826
3006
  .finally(() => {
2827
3007
  active--;
2828
- if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
2829
- const resolvers = drainResolvers;
2830
- drainResolvers = [];
2831
- for (const r of resolvers) r();
2832
- }
3008
+ settleDrain();
2833
3009
  tryStart();
2834
3010
  });
2835
3011
  }
@@ -2838,10 +3014,11 @@ const createPool = (concurrency) => {
2838
3014
  return {
2839
3015
  /**
2840
3016
  * Queue `fn` for execution under the concurrency limit.
2841
- * The returned Promise resolves as soon as a slot is acquired and `fn` has started.
3017
+ * The returned Promise resolves `true` as soon as a slot is acquired and `fn` has started,
3018
+ * or `false` if `cancelQueued()` dropped the job before it started (`fn` never runs).
2842
3019
  * It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
2843
3020
  * @param {() => Promise<void>} fn
2844
- * @returns {Promise<void>}
3021
+ * @returns {Promise<boolean>}
2845
3022
  */
2846
3023
  dispatch(fn) {
2847
3024
  return new Promise((resolveSlot) => {
@@ -2849,6 +3026,16 @@ const createPool = (concurrency) => {
2849
3026
  tryStart();
2850
3027
  });
2851
3028
  },
3029
+ /**
3030
+ * Drop every job still waiting for a slot. Running jobs are untouched.
3031
+ * @returns {number} dropped jobs
3032
+ */
3033
+ cancelQueued() {
3034
+ const dropped = queue.splice(0);
3035
+ for (const job of dropped) job.resolveSlot(false);
3036
+ settleDrain();
3037
+ return dropped.length;
3038
+ },
2852
3039
  drain() {
2853
3040
  if (active === 0 && queue.length === 0) {
2854
3041
  return Promise.resolve();
@@ -2999,9 +3186,21 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2999
3186
  */
3000
3187
 
3001
3188
  /**
3002
- * @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
3003
3190
  */
3004
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
3199
+ */
3200
+
3201
+ const REPROBE_BASE_MS = 60000;
3202
+ const REPROBE_MAX_MS = 900000;
3203
+
3005
3204
  /**
3006
3205
  * Class of MailTime.
3007
3206
  * With recipientPolicies, terminal callbacks receive grouped recipients and a complete summary after persistence.
@@ -3100,9 +3299,11 @@ class MailTime {
3100
3299
  this.__isDestroyed = false;
3101
3300
  this.__abortInFlight = false;
3102
3301
  this.__isPaused = false;
3302
+ this.__schedulerScans = 0;
3103
3303
  this.__readyPromise = null;
3104
3304
  this.__schedulerTimer = null;
3105
3305
  this.__inFlight = new Set();
3306
+ this.__failedWrites = 0;
3106
3307
  this.__pool = createPool(this.concurrency);
3107
3308
 
3108
3309
  this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
@@ -3110,7 +3311,12 @@ class MailTime {
3110
3311
  this.transports = Array.isArray(opts.transports) ? opts.transports : [];
3111
3312
  this.transport = 0;
3112
3313
  this.verifyTransports = opts.verifyTransports !== false;
3314
+ this.verifyTimeout = (typeof opts.verifyTimeout === 'number' && opts.verifyTimeout > 0) ? Math.min(opts.verifyTimeout, 2147483647) : 30000;
3113
3315
  this.__unhealthyTransports = new Set();
3316
+ this.__verifyGen = [];
3317
+ this.__reprobe = new Map();
3318
+ this.__probeCancels = new Set();
3319
+ this.__uncertainRenewals = new WeakMap();
3114
3320
 
3115
3321
  if (typeof opts.from === 'string') {
3116
3322
  const fromStr = opts.from;
@@ -3121,6 +3327,16 @@ class MailTime {
3121
3327
  this.from = false;
3122
3328
  }
3123
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
+
3124
3340
  this.queue.mailTimeInstance = this;
3125
3341
 
3126
3342
  /** @type {string} */
@@ -3277,7 +3493,7 @@ class MailTime {
3277
3493
  /**
3278
3494
  * @memberOf MailTime
3279
3495
  * @name destroy
3280
- * @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if a scheduler handler exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. The timeout does not bound SMTP drain time.
3496
+ * @description Stop the scheduler and block future dispatches. Sends still waiting for a `concurrency` slot are dropped at once; their rows stay unclaimed for the next scan. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if the queue scan exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. In-flight SMTP does not count against the timeout, and the timeout does not bound SMTP drain time.
3281
3497
  * @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
3282
3498
  * @returns {boolean | Promise<boolean>}
3283
3499
  */
@@ -3293,6 +3509,11 @@ class MailTime {
3293
3509
 
3294
3510
  this.__isDestroyed = true;
3295
3511
  this.__isPaused = false;
3512
+ for (const cancel of [...this.__probeCancels]) cancel();
3513
+ this.__reprobe.clear();
3514
+ // Queued jobs never started SMTP; dropping them lets a scan blocked on a
3515
+ // pool slot return, so JoSk shutdown does not wait for in-flight sends.
3516
+ this.__pool.cancelQueued();
3296
3517
  if (opts?.drain === true) {
3297
3518
  return (async () => {
3298
3519
  let finished = true;
@@ -3325,20 +3546,40 @@ class MailTime {
3325
3546
  * @async
3326
3547
  * @memberOf MailTime
3327
3548
  * @name drain
3328
- * @description Wait for all in-flight email send attempts to settle
3329
- * @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>}
3330
3551
  */
3331
3552
  async drain() {
3332
3553
  this.__debug('[drain]');
3333
3554
  if (this.__pool) {
3334
3555
  await this.__pool.drain();
3335
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 });
3336
3577
  }
3337
3578
 
3338
3579
  /**
3339
3580
  * @memberOf MailTime
3340
3581
  * @name pause
3341
- * @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
3582
+ * @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay unclaimed; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
3342
3583
  * @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
3343
3584
  */
3344
3585
  pause() {
@@ -3349,6 +3590,9 @@ class MailTime {
3349
3590
  const paused = this.scheduler.pause();
3350
3591
  if (paused) {
3351
3592
  this.__isPaused = true;
3593
+ if (this.__schedulerScans > 0) {
3594
+ this.__pool.cancelQueued();
3595
+ }
3352
3596
  }
3353
3597
  return paused;
3354
3598
  }
@@ -3434,6 +3678,10 @@ class MailTime {
3434
3678
  throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
3435
3679
  }
3436
3680
 
3681
+ if (this.__recipientPolicies) {
3682
+ validatePolicyMailOptions(mailOptions);
3683
+ }
3684
+
3437
3685
  if (this.concatEmails) {
3438
3686
  sendAt = sendAt + this.concatDelay;
3439
3687
  const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
@@ -3515,21 +3763,15 @@ class MailTime {
3515
3763
  if (task.tries >= this.maxTries) {
3516
3764
  this.___finalizeRejected(task, info);
3517
3765
 
3518
- const leaseRemove = __leaseRemoveOpts(task);
3519
- const leaseUpdate = __withLease(task, {
3520
- isSent: false,
3521
- isFailed: true,
3522
- isSending: false,
3523
- sendingAt: 0,
3524
- mailOptions: task.mailOptions,
3525
- });
3526
-
3527
- let finalized = false;
3528
- if (!this.keepHistory) {
3529
- finalized = await this.queue.remove(task, leaseRemove);
3530
- } else {
3531
- finalized = await this.queue.update(task, leaseUpdate);
3532
- }
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
+ }))));
3533
3775
 
3534
3776
  if (!finalized) {
3535
3777
  this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
@@ -3555,12 +3797,12 @@ class MailTime {
3555
3797
  transportIndex = this.___nextHealthyTransport(transportIndex);
3556
3798
  }
3557
3799
 
3558
- const released = await this.queue.update(task, __withLease(task, {
3800
+ const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
3559
3801
  isSending: false,
3560
3802
  sendingAt: 0,
3561
3803
  sendAt: Date.now() + this.retryDelay,
3562
3804
  transport: transportIndex,
3563
- }));
3805
+ })));
3564
3806
 
3565
3807
  if (!released) {
3566
3808
  this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
@@ -3674,9 +3916,11 @@ class MailTime {
3674
3916
  compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
3675
3917
  }
3676
3918
 
3677
- compiledOpts.html ??= '';
3678
- compiledOpts.text ??= '';
3679
- compiledOpts.subject ??= '';
3919
+ for (const field of ['html', 'text', 'subject']) {
3920
+ if (compiledOpts[field] === void 0 || compiledOpts[field] === null) {
3921
+ compiledOpts[field] = '';
3922
+ }
3923
+ }
3680
3924
 
3681
3925
  const mailOptionsList = task.mailOptions || [];
3682
3926
  const isMulti = mailOptionsList.length > 1;
@@ -3960,6 +4204,10 @@ class MailTime {
3960
4204
  }));
3961
4205
  } catch (renewError) {
3962
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);
3963
4211
  halt();
3964
4212
  return;
3965
4213
  }
@@ -3990,6 +4238,38 @@ class MailTime {
3990
4238
  return { stop };
3991
4239
  }
3992
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
+
3993
4273
  /** @internal Serialize every policy checkpoint with this attempt's claim renewal. */
3994
4274
  async ___sendWithRecipientPolicies(task) {
3995
4275
  if (!task || task.isSent || task.isFailed || task.isCancelled || task.isSettled || this.__abortInFlight) return;
@@ -4004,9 +4284,12 @@ class MailTime {
4004
4284
  }
4005
4285
  Object.assign(task, fields);
4006
4286
  const lease = new RecipientPolicyLease({
4007
- task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals,
4287
+ task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals, sendingTimeout: this.sendingTimeout,
4008
4288
  shouldAbort: () => this.__abortInFlight,
4009
- 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
+ },
4010
4293
  });
4011
4294
  try {
4012
4295
  if (!lease.active) return;
@@ -4062,7 +4345,15 @@ class MailTime {
4062
4345
  context.recipients = context.recipients.filter((r) => eligible.has(r.address));
4063
4346
  await this.___attemptPolicyTransport(task, lease, compiled, context);
4064
4347
  } catch (error) {
4065
- 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
+ }
4066
4357
  } finally {
4067
4358
  await lease.stop();
4068
4359
  }
@@ -4071,7 +4362,7 @@ class MailTime {
4071
4362
  /** @internal Persist SMTP acceptance before awaiting rejection classification or observation. */
4072
4363
  async ___attemptPolicyTransport(task, lease, compiled, context) {
4073
4364
  const transport = this.transports[context.transport.index];
4074
- const outgoing = { ...compiled, envelope: context.envelope };
4365
+ const outgoing = { ...rewritePolicyHeaders(compiled), envelope: context.envelope };
4075
4366
  const { error, info } = await new Promise((resolve) => {
4076
4367
  let called = false;
4077
4368
  const done = (error, info) => {
@@ -4193,12 +4484,12 @@ class MailTime {
4193
4484
  * @internal
4194
4485
  * @memberOf MailTime
4195
4486
  * @name ___dispatch
4196
- * @description Queue full-lifecycle send for `task` under the bounded send pool. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
4487
+ * @description Queue full-lifecycle send for `task` under the bounded send pool. No-op after `destroy()`, or while paused during a scheduler-driven scan; `destroy()` and `pause()` also drop jobs still waiting for a slot. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
4197
4488
  * @param {MailTimeTask} task - email's task object from Storage
4198
4489
  * @returns {Promise<void 0>}
4199
4490
  */
4200
4491
  async ___dispatch(task) {
4201
- if (this.__isDestroyed) {
4492
+ if (this.___isStopped) {
4202
4493
  return;
4203
4494
  }
4204
4495
  if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
@@ -4209,9 +4500,9 @@ class MailTime {
4209
4500
  return;
4210
4501
  }
4211
4502
  this.__inFlight.add(task.uuid);
4212
- await this.__pool.dispatch(async () => {
4503
+ const started = await this.__pool.dispatch(async () => {
4213
4504
  try {
4214
- if (this.__isDestroyed) {
4505
+ if (this.___isStopped) {
4215
4506
  return;
4216
4507
  }
4217
4508
  await this.___send(task);
@@ -4219,6 +4510,20 @@ class MailTime {
4219
4510
  this.__inFlight.delete(task.uuid);
4220
4511
  }
4221
4512
  });
4513
+ if (!started) {
4514
+ this.__inFlight.delete(task.uuid);
4515
+ }
4516
+ }
4517
+
4518
+ /**
4519
+ * @internal
4520
+ * @memberOf MailTime
4521
+ * @name ___isStopped
4522
+ * @description `true` after `destroy()`, or while paused during a scheduler-driven scan. Queue adapters check it to end an `iterate` scan early.
4523
+ * @returns {boolean}
4524
+ */
4525
+ get ___isStopped() {
4526
+ return this.__isDestroyed || (this.__isPaused && this.__schedulerScans > 0);
4222
4527
  }
4223
4528
 
4224
4529
  /**
@@ -4265,6 +4570,7 @@ class MailTime {
4265
4570
  task.tries = tries;
4266
4571
  task.isSending = true;
4267
4572
  task.sendingAt = sendingAt;
4573
+ this.__uncertainRenewals.delete(task);
4268
4574
 
4269
4575
  let transportIndex = task.transport;
4270
4576
  if (!this.___isHealthyTransport(transportIndex)) {
@@ -4318,20 +4624,14 @@ class MailTime {
4318
4624
  if (isFullyDelivered) {
4319
4625
  this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
4320
4626
 
4321
- const leaseRemove = __leaseRemoveOpts(task);
4322
- const leaseUpdate = __withLease(task, {
4323
- isSent: true,
4324
- isSending: false,
4325
- sendingAt: 0,
4326
- mailOptions: task.mailOptions,
4327
- });
4328
-
4329
- let completed = false;
4330
- if (!this.keepHistory) {
4331
- completed = await this.queue.remove(task, leaseRemove);
4332
- } else {
4333
- completed = await this.queue.update(task, leaseUpdate);
4334
- }
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
+ }))));
4335
4635
 
4336
4636
  if (!completed) {
4337
4637
  this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
@@ -4349,21 +4649,15 @@ class MailTime {
4349
4649
  if (task.tries >= this.maxTries) {
4350
4650
  this.___finalizeRejected(task, info);
4351
4651
 
4352
- const leaseRemove = __leaseRemoveOpts(task);
4353
- const leaseUpdate = __withLease(task, {
4354
- isSent: false,
4355
- isFailed: true,
4356
- isSending: false,
4357
- sendingAt: 0,
4358
- mailOptions: task.mailOptions,
4359
- });
4360
-
4361
- let finalized = false;
4362
- if (!this.keepHistory) {
4363
- finalized = await this.queue.remove(task, leaseRemove);
4364
- } else {
4365
- finalized = await this.queue.update(task, leaseUpdate);
4366
- }
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
+ }))));
4367
4661
 
4368
4662
  if (!finalized) {
4369
4663
  this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
@@ -4388,19 +4682,19 @@ class MailTime {
4388
4682
  }
4389
4683
 
4390
4684
  const nextSendAt = Date.now() + this.retryDelay;
4391
- const released = await this.queue.update(task, __withLease(task, {
4685
+ const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
4392
4686
  isSending: false,
4393
4687
  sendingAt: 0,
4394
4688
  sendAt: nextSendAt,
4395
4689
  mailOptions: task.mailOptions,
4396
- }));
4690
+ })));
4397
4691
  if (!released) {
4398
4692
  this.__debug('[private send] lease lost before partial retry release', task.uuid);
4399
4693
  return;
4400
4694
  }
4401
4695
  this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
4402
4696
  } catch (completionError) {
4403
- logError('[private send] completion error after transport callback', completionError);
4697
+ this.___reportWriteFailure(task, completionError);
4404
4698
  } finally {
4405
4699
  resolve();
4406
4700
  }
@@ -4416,7 +4710,11 @@ class MailTime {
4416
4710
  return;
4417
4711
  }
4418
4712
  logError('Exception during runtime:', e);
4419
- await this.___handleError(task, e, {});
4713
+ try {
4714
+ await this.___handleError(task, e, {});
4715
+ } catch (completionError) {
4716
+ this.___reportWriteFailure(task, completionError);
4717
+ }
4420
4718
  }
4421
4719
  }
4422
4720
 
@@ -4433,10 +4731,15 @@ class MailTime {
4433
4731
  return;
4434
4732
  }
4435
4733
  const limit = this.mode === 'one' ? 1 : Infinity;
4436
- return await this.queue.iterate({
4437
- limit,
4438
- sendingTimeout: this.sendingTimeout,
4439
- });
4734
+ this.__schedulerScans++;
4735
+ try {
4736
+ return await this.queue.iterate({
4737
+ limit,
4738
+ sendingTimeout: this.sendingTimeout,
4739
+ });
4740
+ } finally {
4741
+ this.__schedulerScans--;
4742
+ }
4440
4743
  }
4441
4744
 
4442
4745
  /**
@@ -4473,51 +4776,163 @@ class MailTime {
4473
4776
  * @internal
4474
4777
  * @memberOf MailTime
4475
4778
  * @name ___verifyTransports
4476
- * @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.
4477
4780
  * @returns {Promise<void>}
4478
4781
  */
4479
4782
  async ___verifyTransports() {
4480
4783
  this.__debug('[private verifyTransports]');
4481
- const results = await Promise.all(this.transports.map(async (transport, index) => {
4784
+ await Promise.all(this.transports.map(async (transport, index) => {
4482
4785
  if (!transport || typeof transport.verify !== 'function') {
4483
- 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();
4484
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
+
4485
4839
  try {
4486
- await Promise.resolve(transport.verify());
4487
- 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
+ }
4488
4846
  } catch (error) {
4489
- return { index, ok: false, error };
4847
+ decide(error || new Error('[mail-time] [verifyTransports] verify() threw'));
4490
4848
  }
4491
- }));
4849
+ });
4850
+ }
4492
4851
 
4493
- for (const r of results) {
4494
- if (r.ok) {
4495
- 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;
4496
4875
  }
4497
- this.__unhealthyTransports.add(r.index);
4498
- logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
4499
- 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;
4500
4878
  }
4501
-
4502
- if (this.__unhealthyTransports.size === this.transports.length) {
4503
- 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);
4504
4893
  }
4894
+ logError(`[mail-time] [verifyTransports] transport #${index} failed verification`, error);
4895
+ callHook('onError', this.onError, error, null, { transportIndex: index, phase: 'verify' });
4896
+ }
4505
4897
 
4506
- if (this.__unhealthyTransports.has(this.transport)) {
4507
- 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;
4508
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);
4509
4920
  }
4510
4921
 
4511
4922
  /**
4512
4923
  * @internal
4513
4924
  * @memberOf MailTime
4514
4925
  * @name ___isHealthyTransport
4515
- * @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.
4516
4927
  * @param {number} index
4517
4928
  * @returns {boolean}
4518
4929
  */
4519
4930
  ___isHealthyTransport(index) {
4520
- return !this.__unhealthyTransports.has(index);
4931
+ if (!this.__unhealthyTransports.has(index)) {
4932
+ return true;
4933
+ }
4934
+ this.___maybeReprobe(index);
4935
+ return false;
4521
4936
  }
4522
4937
 
4523
4938
  /**