mail-time 4.0.0 → 5.0.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
@@ -1,15 +1,60 @@
1
1
  import { JoSk, RedisAdapter, MongoAdapter, PostgresAdapter } from 'josk';
2
- import { randomUUID } from 'node:crypto';
2
+ import { randomUUID } from 'crypto';
3
3
 
4
4
  import { MongoQueue } from './adapters/mongo.js';
5
5
  import { RedisQueue } from './adapters/redis.js';
6
6
  import { PostgresQueue } from './adapters/postgres.js';
7
7
  import { mailTimePreset, presets, presetNames } from './presets.js';
8
- import { debug, logError, deepMerge, equals, isPlainObject, extractEmail, toAddressList, filterAddressField } from './helpers.js';
8
+ import { debug, escapeHtml, logError, hasOwnProp, deepMerge, equals, isPlainObject, extractEmail, toAddressList, filterAddressField } from './helpers.js';
9
9
 
10
10
  const noop = () => {};
11
11
  const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
12
12
 
13
+ const callHook = (name, callback, ...args) => {
14
+ try {
15
+ const result = callback(...args);
16
+ if (result && typeof result.then === 'function') {
17
+ Promise.resolve(result).catch((hookError) => {
18
+ logError(`[${name}] callback failed`, hookError);
19
+ });
20
+ }
21
+ } catch (hookError) {
22
+ logError(`[${name}] callback failed`, hookError);
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Floor below which `sendingTimeout` is likely shorter than a real SMTP roundtrip
28
+ * (connect + greeting + STARTTLS + envelope + DATA, times MX rollover). A claim that
29
+ * expires mid-send is re-claimed by a peer, which delivers the same letter twice.
30
+ */
31
+ const MIN_SAFE_SENDING_TIMEOUT = 120000;
32
+ const DEFAULT_MAX_RENEWALS = 10;
33
+
34
+ /**
35
+ * Fields a queued task may set under `{strictPayload: true}`. Everything else —
36
+ * `attachments`, `envelope`, `dkim`, `raw`, `icalEvent`, … — is dropped, because a
37
+ * process with queue-write access would otherwise inherit nodemailer's whole
38
+ * capability surface (local file read, URL fetch, sender/DKIM override).
39
+ */
40
+ const STRICT_MAIL_FIELDS = [
41
+ 'to',
42
+ 'cc',
43
+ 'bcc',
44
+ 'replyTo',
45
+ 'subject',
46
+ 'text',
47
+ 'html',
48
+ 'headers',
49
+ 'list',
50
+ 'priority',
51
+ 'encoding',
52
+ 'textEncoding',
53
+ 'inReplyTo',
54
+ 'references',
55
+ 'date',
56
+ ];
57
+
13
58
  const __withLease = (task, updateObj) => {
14
59
  if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
15
60
  return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
@@ -128,7 +173,7 @@ const buildRejectionErrorMap = (info) => {
128
173
  return map;
129
174
  };
130
175
 
131
- let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml><meta content="text/html; charset=utf-8"http-equiv=Content-Type><meta content="width=device-width,initial-scale=1"name=viewport><title>{{subject}}</title><style>body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;font-family:Tiempos,Georgia,Times,serif;font-weight:400;width:100%;height:100%;background:#fff;font-size:15px;color:#000;line-height:1.5}a{text-decoration:underline;border:0;color:#000;outline:0;color:inherit}a:hover{text-decoration:none}a[href^=sms],a[href^=tel]{text-decoration:none;color:#000;cursor:default}a img{border:none;text-decoration:none}td{font-family:Tiempos,Georgia,Times,serif;font-weight:400}hr{height:1px;border:none;width:100%;margin:0;margin-top:25px;margin-bottom:25px;background-color:#ECECEC}h1,h2,h3,h4,h5,h6{font-family:HelveticaNeue,"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;line-height:normal;margin-top:35px;margin-bottom:4px;margin-left:0;margin-right:0}h1{margin:23px 15px;font-size:25px}h2{margin-top:15px;font-size:21px}h3{font-weight:400;font-size:19px;border-bottom:1px solid #ECECEC}h4{font-weight:400;font-size:18px}h5{font-weight:400;font-size:17px}h6{font-weight:600;font-size:16px}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{text-decoration:none}pre{font-family:Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;display:block;font-size:13px;padding:9.5px;margin:0 0 10px;line-height:1.42;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px;text-align:left!important;max-width:100%;white-space:pre-wrap;width:auto;overflow:auto}code{font-size:13px;font-family:font-family: Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;border:1px solid rgba(0,0,0,.223);border-radius:2px;padding:1px 2px;word-break:break-all;word-wrap:break-word}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border:none;border-radius:0;word-break:break-all;word-wrap:break-word}td{text-align:center}table{border-collapse:collapse!important}.force-full-width{width:100%!important}</style><style media=screen>@media screen{h1,h2,h3,h4,h5,h6{font-family:\'Helvetica Neue\',Arial,sans-serif!important}td{font-family:Tiempos,Georgia,Times,serif!important}code,pre{font-family:Consolas,Menlo,Monaco,\'Lucida Console\',\'Liberation Mono\',\'DejaVu Sans Mono\',\'Bitstream Vera Sans Mono\',\'Courier New\',monospace,sans-serif!important}}</style><style media="only screen and (max-width:480px)">@media only screen and (max-width:480px){table[class=w320]{width:100%!important}}</style><body bgcolor=#FFFFFF class=body style=padding:0;margin:0;display:block;background:#fff;-webkit-text-size-adjust:none><table cellpadding=0 cellspacing=0 width=100% align=center><tr><td align=center valign=top bgcolor=#FFFFFF width=100%><center><table cellpadding=0 cellspacing=0 width=600 style="margin:0 auto"class=w320><tr><td align=center valign=top><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto;border-bottom:1px solid #ddd"bgcolor=#ECECEC><tr><td><h1>{{{subject}}}</h1></table><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"bgcolor=#F2F2F2><tr><td><center><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"><tr><td align=left style="text-align:left;padding:30px 25px">{{{html}}}</table></center></table></table></center></table>';
176
+ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml><meta content="text/html; charset=utf-8"http-equiv=Content-Type><meta content="width=device-width,initial-scale=1"name=viewport><title>{{subject}}</title><style>body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;font-family:Tiempos,Georgia,Times,serif;font-weight:400;width:100%;height:100%;background:#fff;font-size:15px;color:#000;line-height:1.5}a{text-decoration:underline;border:0;color:#000;outline:0;color:inherit}a:hover{text-decoration:none}a[href^=sms],a[href^=tel]{text-decoration:none;color:#000;cursor:default}a img{border:none;text-decoration:none}td{font-family:Tiempos,Georgia,Times,serif;font-weight:400}hr{height:1px;border:none;width:100%;margin:0;margin-top:25px;margin-bottom:25px;background-color:#ECECEC}h1,h2,h3,h4,h5,h6{font-family:HelveticaNeue,"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;line-height:normal;margin-top:35px;margin-bottom:4px;margin-left:0;margin-right:0}h1{margin:23px 15px;font-size:25px}h2{margin-top:15px;font-size:21px}h3{font-weight:400;font-size:19px;border-bottom:1px solid #ECECEC}h4{font-weight:400;font-size:18px}h5{font-weight:400;font-size:17px}h6{font-weight:600;font-size:16px}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{text-decoration:none}pre{font-family:Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;display:block;font-size:13px;padding:9.5px;margin:0 0 10px;line-height:1.42;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px;text-align:left!important;max-width:100%;white-space:pre-wrap;width:auto;overflow:auto}code{font-size:13px;font-family:font-family: Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;border:1px solid rgba(0,0,0,.223);border-radius:2px;padding:1px 2px;word-break:break-all;word-wrap:break-word}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border:none;border-radius:0;word-break:break-all;word-wrap:break-word}td{text-align:center}table{border-collapse:collapse!important}.force-full-width{width:100%!important}</style><style media=screen>@media screen{h1,h2,h3,h4,h5,h6{font-family:\'Helvetica Neue\',Arial,sans-serif!important}td{font-family:Tiempos,Georgia,Times,serif!important}code,pre{font-family:Consolas,Menlo,Monaco,\'Lucida Console\',\'Liberation Mono\',\'DejaVu Sans Mono\',\'Bitstream Vera Sans Mono\',\'Courier New\',monospace,sans-serif!important}}</style><style media="only screen and (max-width:480px)">@media only screen and (max-width:480px){table[class=w320]{width:100%!important}}</style><body bgcolor=#FFFFFF class=body style=padding:0;margin:0;display:block;background:#fff;-webkit-text-size-adjust:none><table cellpadding=0 cellspacing=0 width=100% align=center><tr><td align=center valign=top bgcolor=#FFFFFF width=100%><center><table cellpadding=0 cellspacing=0 width=600 style="margin:0 auto"class=w320><tr><td align=center valign=top><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto;border-bottom:1px solid #ddd"bgcolor=#ECECEC><tr><td><h1>{{subject}}</h1></table><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"bgcolor=#F2F2F2><tr><td><center><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"><tr><td align=left style="text-align:left;padding:30px 25px">{{{html}}}</table></center></table></table></center></table>';
132
177
 
133
178
  /**
134
179
  * @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
@@ -139,7 +184,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
139
184
  */
140
185
 
141
186
  /**
142
- * @typedef {{ status: string, code: number, statusCode: number, error?: unknown }} MailTimePingResult
187
+ * @typedef {{ status: string, code: number, statusCode: number, paused?: boolean, error?: unknown }} MailTimePingResult
143
188
  */
144
189
 
145
190
  /**
@@ -159,7 +204,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
159
204
  */
160
205
 
161
206
  /**
162
- * @typedef {{ [key: string]: any, adapter: MailTimeJoSkAdapterOptions | object, debug?: boolean, autoClear?: boolean, zombieTime?: number, minRevolvingDelay?: number, maxRevolvingDelay?: number, execute?: 'batch' | 'one', concurrency?: number, lockOwnerId?: string, resetOnInit?: boolean, onError?: (title: string, details: object) => void, onExecuted?: (uid: string, details: object) => void }} MailTimeJoSkOptions
207
+ * @typedef {{ [key: string]: any, adapter: MailTimeJoSkAdapterOptions | object, debug?: boolean, autoClear?: boolean, zombieTime?: number, lockLeaseTime?: number, minRevolvingDelay?: number, maxRevolvingDelay?: number, execute?: 'batch' | 'one', concurrency?: number, lockOwnerId?: string, resetOnInit?: boolean, onError?: (title: string, details: object) => void, onExecuted?: (uid: string, details: object) => void }} MailTimeJoSkOptions
208
+ */
209
+
210
+ /**
211
+ * @typedef {{ [key: string]: any, ping: () => Promise<MailTimePingResult>, setInterval: (func: (...args: any[]) => unknown, delay: number, uid: string) => Promise<string>, destroy: () => boolean, pause: (timerId?: string) => boolean, resume: (timerId?: string) => boolean }} MailTimeScheduler
163
212
  */
164
213
 
165
214
  /**
@@ -187,7 +236,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
187
236
  */
188
237
 
189
238
  /**
190
- * @typedef {{ queue: RedisQueue | MongoQueue | PostgresQueue | CustomQueue, type?: 'server' | 'client', from?: string | ((transport: MailTimeTransport) => string), transports?: MailTimeTransport[], strategy?: 'backup' | 'balancer', failsToNext?: number, 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, verifyTransports?: boolean, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, onError?: (error: unknown, email: MailTimeTask | null, details?: object) => void, onSent?: (email: MailTimeTask, details?: object) => void }} MailTimeOptions
239
+ * @typedef {{ index: number, from: string | undefined }} MailTimeFromDetails
240
+ */
241
+
242
+ /**
243
+ * @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, onError?: (error: unknown, email: MailTimeTask | null, details?: object) => void, onSent?: (email: MailTimeTask, details?: object) => void }} MailTimeOptions
191
244
  */
192
245
 
193
246
  /** Class of MailTime */
@@ -248,8 +301,37 @@ class MailTime {
248
301
  this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
249
302
  this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
250
303
  this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
251
- this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
304
+ this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && Number.isFinite(opts.sendingTimeout) && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
305
+ if (this.sendingTimeout < MIN_SAFE_SENDING_TIMEOUT) {
306
+ logError(`{sendingTimeout: ${this.sendingTimeout}} is below the ${MIN_SAFE_SENDING_TIMEOUT}ms safe floor — a claim that expires while SMTP is still in flight is re-claimed by a peer and the letter is delivered twice. Raise {sendingTimeout} above the worst-case SMTP roundtrip.`);
307
+ }
308
+
309
+ // Claim renewal: MailTime has no way to know how long a transport will take, so a
310
+ // long-but-healthy send used to lose its lock to a "recovery" worker. While a send
311
+ // is in flight the claim is re-stamped every {renewClaim} ms — bounded by
312
+ // {maxRenewals} so a genuinely wedged send is still recovered, just later.
313
+ if (opts.renewClaim === false || opts.renewClaim === 0) {
314
+ this.renewClaim = 0;
315
+ } else if (typeof opts.renewClaim === 'number' && Number.isFinite(opts.renewClaim) && opts.renewClaim > 0) {
316
+ this.renewClaim = Math.floor(opts.renewClaim);
317
+ } else {
318
+ this.renewClaim = Math.max(1000, Math.floor(this.sendingTimeout / 3));
319
+ }
320
+ this.maxRenewals = (typeof opts.maxRenewals === 'number' && Number.isFinite(opts.maxRenewals) && opts.maxRenewals >= 0) ? Math.floor(opts.maxRenewals) : DEFAULT_MAX_RENEWALS;
321
+
322
+ // Transport fail-over policy. Rotating on *any* error can resubmit a letter the
323
+ // MX already accepted; a transport that knows better says so.
324
+ this.shouldFailOver = (typeof opts.shouldFailOver === 'function') ? opts.shouldFailOver.bind(this) : null;
325
+
326
+ this.strictPayload = opts.strictPayload === true;
327
+ this.allowedMailFields = new Set([
328
+ ...STRICT_MAIL_FIELDS,
329
+ ...(Array.isArray(opts.allowedMailFields) ? opts.allowedMailFields.filter((field) => typeof field === 'string') : []),
330
+ ]);
331
+
252
332
  this.__isDestroyed = false;
333
+ this.__abortInFlight = false;
334
+ this.__isPaused = false;
253
335
  this.__readyPromise = null;
254
336
  this.__schedulerTimer = null;
255
337
  this.__inFlight = new Set();
@@ -305,6 +387,8 @@ class MailTime {
305
387
  this.__debug(`INITIALIZING [mode: ${this.mode}]`);
306
388
  this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
307
389
  this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
390
+ this.__debug(`INITIALIZING [renewClaim: ${this.renewClaim}]`);
391
+ this.__debug(`INITIALIZING [strictPayload: ${this.strictPayload}]`);
308
392
 
309
393
  /** SERVER-SPECIFIC CHECKS AND CONFIG */
310
394
  if (this.type === 'server') {
@@ -369,6 +453,7 @@ class MailTime {
369
453
  };
370
454
  }
371
455
 
456
+ /** @type {MailTimeScheduler | undefined} */
372
457
  this.scheduler = new JoSk({
373
458
  debug: this.debug,
374
459
  ...this.josk,
@@ -402,10 +487,11 @@ class MailTime {
402
487
  if (this.scheduler) {
403
488
  const schedulerPing = await this.scheduler.ping();
404
489
  if (schedulerPing.status !== 'OK') {
405
- return schedulerPing;
490
+ return { ...schedulerPing, paused: this.__isPaused };
406
491
  }
407
492
  }
408
- return await this.queue.ping();
493
+ const queuePing = await this.queue.ping();
494
+ return { ...queuePing, paused: this.__isPaused };
409
495
  }
410
496
 
411
497
  /**
@@ -423,7 +509,7 @@ class MailTime {
423
509
  /**
424
510
  * @memberOf MailTime
425
511
  * @name destroy
426
- * @description Stop the scheduler and block future dispatches. Pass `{ drain: true }` to also wait for in-flight SMTP attempts (returns a Promise).
512
+ * @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, any in-flight SMTP attempts are neutralized on completion — they perform no storage writes, no `onSent`/`onError` callbacks, and no logging once `destroy()` returns; their claimed rows are recovered by stale-lock timeout (`sendingTimeout`). Pass `{ drain: true }` to instead let in-flight attempts run to completion (returns a Promise that resolves once they settle).
427
513
  * @param {{ drain?: boolean }} [opts]
428
514
  * @returns {boolean | Promise<boolean>}
429
515
  */
@@ -434,12 +520,21 @@ class MailTime {
434
520
  }
435
521
 
436
522
  this.__isDestroyed = true;
523
+ this.__isPaused = false;
437
524
  if (this.scheduler && typeof this.scheduler.destroy === 'function') {
438
525
  this.scheduler.destroy();
439
526
  }
440
527
  if (opts && opts.drain === true && this.__pool) {
441
528
  return this.__pool.drain().then(() => true);
442
529
  }
530
+ // No graceful drain requested: neutralize in-flight SMTP completions so a
531
+ // destroyed instance performs no storage writes, no `onSent`/`onError`
532
+ // callbacks, and no logging after teardown. Each claimed row keeps its
533
+ // `isSending` lock, which a peer or a future instance recovers once
534
+ // `sendingTimeout` elapses — identical to a crash mid-send. Use
535
+ // `destroy({ drain: true })` (or `await drain()` before `destroy()`) when
536
+ // in-flight sends must run to completion.
537
+ this.__abortInFlight = true;
443
538
  return true;
444
539
  }
445
540
 
@@ -457,6 +552,52 @@ class MailTime {
457
552
  }
458
553
  }
459
554
 
555
+ /**
556
+ * @memberOf MailTime
557
+ * @name pause
558
+ * @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();`.
559
+ * @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
560
+ */
561
+ pause() {
562
+ this.__debug('[pause]');
563
+ if (this.__isDestroyed || !this.scheduler) {
564
+ return false;
565
+ }
566
+ const paused = this.scheduler.pause();
567
+ if (paused) {
568
+ this.__isPaused = true;
569
+ }
570
+ return paused;
571
+ }
572
+
573
+ /**
574
+ * @memberOf MailTime
575
+ * @name resume
576
+ * @description Resume competing for the queue-drain lease after `pause()`; triggers an immediate scan. No-op on `client` instances, after `destroy()`, or when not paused.
577
+ * @returns {boolean} `true` if newly resumed; `false` if not paused, a client instance, or destroyed
578
+ */
579
+ resume() {
580
+ this.__debug('[resume]');
581
+ if (this.__isDestroyed || !this.scheduler) {
582
+ return false;
583
+ }
584
+ const resumed = this.scheduler.resume();
585
+ if (resumed) {
586
+ this.__isPaused = false;
587
+ }
588
+ return resumed;
589
+ }
590
+
591
+ /**
592
+ * @memberOf MailTime
593
+ * @name isPaused
594
+ * @description Whether this instance is currently paused from draining the queue. Always `false` on `client` instances.
595
+ * @returns {boolean}
596
+ */
597
+ get isPaused() {
598
+ return this.__isPaused;
599
+ }
600
+
460
601
  /**
461
602
  * @memberOf MailTime
462
603
  * @name send
@@ -478,12 +619,17 @@ class MailTime {
478
619
  * @returns {Promise<string>} uuid of the email
479
620
  * @throws {Error}
480
621
  */
481
- async sendMail(opts = {}) {
622
+ async sendMail(opts) {
623
+ opts = (opts && typeof opts === 'object') ? opts : {};
482
624
  this.__debug('[sendMail]', opts);
483
625
  if (!opts.html && !opts.text) {
484
626
  throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
485
627
  }
486
628
 
629
+ if (opts.raw !== void 0) {
630
+ throw new Error('[mail-time] [sendMail] `raw` is not supported: it bypasses composition, so `template`, `concatEmails`, and the `from()` callback would silently stop applying — and nodemailer historically let a message-level `raw` bypass `disableFileAccess`/`disableUrlAccess`. Pass `html` / `text` instead.');
631
+ }
632
+
487
633
  let sendAt = opts.sendAt;
488
634
  if (sendAt instanceof Date) {
489
635
  sendAt = +sendAt;
@@ -574,6 +720,10 @@ class MailTime {
574
720
  */
575
721
  async ___handleError(task, error, info) {
576
722
  this.__debug('[private handleError]', { task, error, info });
723
+ if (this.__abortInFlight) {
724
+ this.__debug('[private handleError] instance destroyed; skipping onError', task?.uuid);
725
+ return;
726
+ }
577
727
  if (!task) {
578
728
  return;
579
729
  }
@@ -608,13 +758,16 @@ class MailTime {
608
758
  task.isSending = false;
609
759
 
610
760
  this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
611
- this.onError(error, task, info);
761
+ callHook('onError', this.onError, error, task, info);
612
762
  return;
613
763
  }
614
764
 
615
765
  let transportIndex = task.transport;
616
766
 
617
- if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) {
767
+ if (this.strategy === 'backup'
768
+ && this.transports.length > 1
769
+ && (task.tries % this.failsToNext) === 0
770
+ && this.___mayFailOver(error, info, task)) {
618
771
  transportIndex = this.___nextHealthyTransport(transportIndex);
619
772
  }
620
773
 
@@ -676,18 +829,24 @@ class MailTime {
676
829
  * @internal
677
830
  * @memberOf MailTime
678
831
  * @name ___render
679
- * @description Render Mustache-like placeholders
832
+ * @description Render Mustache-like placeholders. `{{{key}}}` is always verbatim.
833
+ * `{{key}}` is HTML-escaped in HTML contexts (`html`, `template`, `concatDelimiter`) —
834
+ * that is what double-brace means everywhere else, and the previous strip-tags pass was
835
+ * not a safety net: it needs a closing `>`, so an unterminated `<a href="…` survived
836
+ * into the rendered body. Text bodies and subject headers are not HTML, so `{{key}}`
837
+ * substitutes verbatim there.
680
838
  * @param {string} _string - Template with Mustache-like placeholders
681
839
  * @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
840
+ * @param {boolean} [isHtml=true] - `false` for text/plain bodies and header values
682
841
  * @returns {string}
683
842
  */
684
- ___render(_string, replacements) {
843
+ ___render(_string, replacements, isHtml = true) {
685
844
  let string = _string;
686
845
  const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
687
846
  if (matchHTML) {
688
847
  for (let i = 0; i < matchHTML.length; i++) {
689
848
  const key = matchHTML[i].slice(3, -3).trim();
690
- if (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
849
+ if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
691
850
  string = string.replace(matchHTML[i], `${replacements[key]}`);
692
851
  }
693
852
  }
@@ -697,8 +856,9 @@ class MailTime {
697
856
  if (matchStr) {
698
857
  for (let i = 0; i < matchStr.length; i++) {
699
858
  const key = matchStr[i].slice(2, -2).trim();
700
- if (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
701
- string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
859
+ if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
860
+ const value = `${replacements[key]}`;
861
+ string = string.replace(matchStr[i], isHtml ? escapeHtml(value) : value);
702
862
  }
703
863
  }
704
864
  }
@@ -737,7 +897,7 @@ class MailTime {
737
897
  const isMulti = mailOptionsList.length > 1;
738
898
 
739
899
  for (let i = 0; i < mailOptionsList.length; i++) {
740
- const mailOption = { ...mailOptionsList[i] };
900
+ const mailOption = this.___applyPayloadPolicy({ ...mailOptionsList[i] });
741
901
 
742
902
  if (mailOption.html) {
743
903
  const rendered = this.___render(mailOption.html, mailOption);
@@ -750,7 +910,7 @@ class MailTime {
750
910
  }
751
911
 
752
912
  if (mailOption.text) {
753
- const rendered = this.___render(mailOption.text, mailOption);
913
+ const rendered = this.___render(mailOption.text, mailOption, false);
754
914
  if (isMulti) {
755
915
  compiledOpts.text += '\r\n' + rendered;
756
916
  } else {
@@ -768,11 +928,14 @@ class MailTime {
768
928
 
769
929
  if (isMulti) {
770
930
  const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
771
- compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
931
+ compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length }, false);
772
932
  }
773
933
 
774
934
  if (!compiledOpts.from && this.from) {
775
- compiledOpts.from = this.from(transport);
935
+ compiledOpts.from = this.from(transport, {
936
+ index: this.transports.indexOf(transport),
937
+ from: MailTime.transportFrom(transport),
938
+ });
776
939
  }
777
940
 
778
941
  const acceptedSet = collectAcceptedSet(task);
@@ -782,9 +945,84 @@ class MailTime {
782
945
  compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
783
946
  }
784
947
 
948
+ if (this.strictPayload) {
949
+ compiledOpts.disableFileAccess = true;
950
+ compiledOpts.disableUrlAccess = true;
951
+ }
952
+
785
953
  return compiledOpts;
786
954
  }
787
955
 
956
+ /**
957
+ * @internal
958
+ * @memberOf MailTime
959
+ * @name ___applyPayloadPolicy
960
+ * @description Narrow one queued mail option to what a task is allowed to set. `raw` is
961
+ * always dropped: it bypasses composition (so `template`, `concatEmails` and the
962
+ * `from()` callback silently stop applying) and historically bypassed nodemailer's
963
+ * `disableFileAccess`/`disableUrlAccess` with it. Under `{strictPayload: true}` only
964
+ * `allowedMailFields` survive.
965
+ * @param {MailTimeMailOptions} mailOption - a single queued mail option object
966
+ * @returns {MailTimeMailOptions}
967
+ */
968
+ ___applyPayloadPolicy(mailOption) {
969
+ if (mailOption.raw !== void 0) {
970
+ delete mailOption.raw;
971
+ logError('[___compileMailOpts] dropped {raw} from a queued task — `raw` bypasses composition and is never sent by MailTime');
972
+ }
973
+
974
+ if (!this.strictPayload) {
975
+ return mailOption;
976
+ }
977
+
978
+ const allowed = {};
979
+ for (const key of Object.keys(mailOption)) {
980
+ if (this.allowedMailFields.has(key)) {
981
+ allowed[key] = mailOption[key];
982
+ } else {
983
+ this.__debug('[___applyPayloadPolicy] dropped non-allowlisted field', key);
984
+ }
985
+ }
986
+ return allowed;
987
+ }
988
+
989
+ /**
990
+ * @static
991
+ * @memberOf MailTime
992
+ * @name transportFrom
993
+ * @description Best-effort sender address for a transport. `nodemailer.createTransport()`
994
+ * only exposes `.options` for *plain-object* configs — for a class-instance transporter
995
+ * (anything with its own `.send()`) nodemailer's internal `Mail.options` is always `{}`,
996
+ * so `transport.options.from` silently reads `undefined`. This walks the places the
997
+ * address can actually live.
998
+ * @param {MailTimeTransport} transport
999
+ * @returns {string | undefined}
1000
+ */
1001
+ static transportFrom(transport) {
1002
+ const candidates = [
1003
+ transport?.options?.from,
1004
+ transport?.transporter?.options?.from,
1005
+ transport?._defaults?.from,
1006
+ transport?._options?.from,
1007
+ ];
1008
+
1009
+ for (const candidate of candidates) {
1010
+ // nodemailer accepts `from` as a string or as `{ name, address }`. Resolve the
1011
+ // first candidate that yields a usable address — not the first truthy slot, so a
1012
+ // malformed `{ name }` object cannot mask a real address further down the chain.
1013
+ const address = (typeof candidate === 'string')
1014
+ ? candidate
1015
+ : (candidate && typeof candidate === 'object' && typeof candidate.address === 'string')
1016
+ ? candidate.address
1017
+ : '';
1018
+ if (address.trim()) {
1019
+ return address;
1020
+ }
1021
+ }
1022
+
1023
+ return void 0;
1024
+ }
1025
+
788
1026
  /**
789
1027
  * @internal
790
1028
  * @memberOf MailTime
@@ -849,6 +1087,124 @@ class MailTime {
849
1087
  }
850
1088
  }
851
1089
 
1090
+ /**
1091
+ * @internal
1092
+ * @memberOf MailTime
1093
+ * @name ___mayFailOver
1094
+ * @description Whether this failure may be retried on the *next* transport. Rotating on
1095
+ * any error can resubmit a letter the receiving MTA already accepted (or may have
1096
+ * accepted — a socket that died during `DATA` is indistinguishable from one that died
1097
+ * before it). A transport signals "do not fail over" with `error.mayFailOver = false`;
1098
+ * the `shouldFailOver(error, info, task)` option overrides both.
1099
+ * @param {unknown} error - error returned by the transport
1100
+ * @param {object|undefined} info - info object returned by the transport
1101
+ * @param {MailTimeTask} task - email's task object from Storage
1102
+ * @returns {boolean}
1103
+ */
1104
+ ___mayFailOver(error, info, task) {
1105
+ if (this.shouldFailOver) {
1106
+ try {
1107
+ return this.shouldFailOver(error, info, task) !== false;
1108
+ } catch (policyError) {
1109
+ logError('[shouldFailOver] callback failed; keeping the current transport', policyError);
1110
+ return false;
1111
+ }
1112
+ }
1113
+ return !(error && typeof error === 'object' && error.mayFailOver === false);
1114
+ }
1115
+
1116
+ /**
1117
+ * @internal
1118
+ * @memberOf MailTime
1119
+ * @name ___startClaimRenewal
1120
+ * @description Hold a claim alive while its SMTP roundtrip runs. MailTime 4.x stamped
1121
+ * `sendingAt` once at claim time, so any send slower than `sendingTimeout` lost its lock
1122
+ * to a "recovery" worker and the letter went out twice. The renewal is a lease-guarded
1123
+ * update — it succeeds only while *this* worker still owns the row — and is bounded by
1124
+ * `maxRenewals` so a genuinely wedged send is still recovered.
1125
+ * @param {MailTimeTask} task - claimed task, with `isSending`/`sendingAt`/`tries` set
1126
+ * @returns {{ stop: () => Promise<void> }}
1127
+ */
1128
+ ___startClaimRenewal(task) {
1129
+ if (!this.renewClaim || !this.maxRenewals || this.type !== 'server') {
1130
+ return { stop: async () => {} };
1131
+ }
1132
+
1133
+ let stopped = false;
1134
+ let renewals = 0;
1135
+ let timer = null;
1136
+ let activeRenewal = null;
1137
+ const halt = () => {
1138
+ if (stopped) {
1139
+ return;
1140
+ }
1141
+ stopped = true;
1142
+ if (timer) {
1143
+ clearInterval(timer);
1144
+ timer = null;
1145
+ }
1146
+ };
1147
+ const stop = async () => {
1148
+ halt();
1149
+ if (activeRenewal) {
1150
+ await activeRenewal;
1151
+ }
1152
+ };
1153
+
1154
+ const renew = async () => {
1155
+ if (stopped || this.__isDestroyed || this.__abortInFlight) {
1156
+ halt();
1157
+ return;
1158
+ }
1159
+
1160
+ if (renewals >= this.maxRenewals) {
1161
+ this.__debug('[private renewClaim] renewal budget exhausted; letting the claim go stale', task.uuid);
1162
+ halt();
1163
+ return;
1164
+ }
1165
+ renewals += 1;
1166
+
1167
+ // Strictly greater than the current stamp: storages that report "modified" only on
1168
+ // an actual value change would otherwise read a same-millisecond renewal as a no-op.
1169
+ const renewedAt = Math.max(Date.now(), (typeof task.sendingAt === 'number' ? task.sendingAt : 0) + 1);
1170
+ let renewed = false;
1171
+ try {
1172
+ renewed = await this.queue.update(task, __withLease(task, {
1173
+ isSending: true,
1174
+ sendingAt: renewedAt,
1175
+ }));
1176
+ } catch (renewError) {
1177
+ logError('[private renewClaim] storage error during claim renewal', renewError);
1178
+ halt();
1179
+ return;
1180
+ }
1181
+
1182
+ if (!renewed) {
1183
+ this.__debug('[private renewClaim] lease lost; stopping renewal', task.uuid);
1184
+ halt();
1185
+ return;
1186
+ }
1187
+
1188
+ task.sendingAt = renewedAt;
1189
+ this.__debug(`[private renewClaim] renewed ${renewals}/${this.maxRenewals}`, task.uuid);
1190
+ };
1191
+
1192
+ timer = setInterval(() => {
1193
+ if (activeRenewal) {
1194
+ return;
1195
+ }
1196
+ activeRenewal = renew().finally(() => {
1197
+ activeRenewal = null;
1198
+ });
1199
+ }, this.renewClaim);
1200
+
1201
+ if (typeof timer.unref === 'function') {
1202
+ timer.unref();
1203
+ }
1204
+
1205
+ return { stop };
1206
+ }
1207
+
852
1208
  /**
853
1209
  * @async
854
1210
  * @internal
@@ -929,127 +1285,147 @@ class MailTime {
929
1285
  const transport = this.transports[transportIndex];
930
1286
 
931
1287
  const compiledOpts = this.___compileMailOpts(transport, task);
1288
+ const renewal = this.___startClaimRenewal(task);
932
1289
 
933
- await new Promise((resolve) => {
934
- transport.sendMail(compiledOpts, async (error, info) => {
935
- this.__debug('[private send] [sending]', { error, info });
936
- try {
937
- if (error) {
938
- await this.___handleError(task, error, info);
1290
+ try {
1291
+ await new Promise((resolve) => {
1292
+ transport.sendMail(compiledOpts, async (error, info) => {
1293
+ // The roundtrip has settled — stop extending the lease before any completion
1294
+ // write, so the guard below sees the last `sendingAt` this worker persisted.
1295
+ await renewal.stop();
1296
+ if (this.__abortInFlight) {
1297
+ this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
1298
+ resolve();
939
1299
  return;
940
1300
  }
1301
+ this.__debug('[private send] [sending]', { error, info });
1302
+ try {
1303
+ if (error) {
1304
+ await this.___handleError(task, error, info);
1305
+ return;
1306
+ }
941
1307
 
942
- const acceptedAddrs = Array.isArray(info?.accepted)
943
- ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
944
- : [];
1308
+ const acceptedAddrs = Array.isArray(info?.accepted)
1309
+ ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
1310
+ : [];
945
1311
 
946
- if (acceptedAddrs.length === 0) {
947
- await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
948
- return;
949
- }
1312
+ if (acceptedAddrs.length === 0) {
1313
+ await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
1314
+ return;
1315
+ }
950
1316
 
951
- this.___trackAcceptedRecipients(task, acceptedAddrs);
1317
+ this.___trackAcceptedRecipients(task, acceptedAddrs);
952
1318
 
953
- const allRecipients = collectAllRecipients(task);
954
- const allAccepted = collectAcceptedSet(task);
955
- let isFullyDelivered = true;
956
- for (const addr of allRecipients) {
957
- if (!allAccepted.has(addr)) {
958
- isFullyDelivered = false;
959
- break;
1319
+ const allRecipients = collectAllRecipients(task);
1320
+ const allAccepted = collectAcceptedSet(task);
1321
+ let isFullyDelivered = true;
1322
+ for (const addr of allRecipients) {
1323
+ if (!allAccepted.has(addr)) {
1324
+ isFullyDelivered = false;
1325
+ break;
1326
+ }
960
1327
  }
961
- }
962
-
963
- if (isFullyDelivered) {
964
- this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
965
1328
 
966
- const leaseRemove = __leaseRemoveOpts(task);
967
- const leaseUpdate = __withLease(task, {
968
- isSent: true,
969
- isSending: false,
970
- sendingAt: 0,
971
- mailOptions: task.mailOptions,
972
- });
1329
+ if (isFullyDelivered) {
1330
+ this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
1331
+
1332
+ const leaseRemove = __leaseRemoveOpts(task);
1333
+ const leaseUpdate = __withLease(task, {
1334
+ isSent: true,
1335
+ isSending: false,
1336
+ sendingAt: 0,
1337
+ mailOptions: task.mailOptions,
1338
+ });
1339
+
1340
+ let completed = false;
1341
+ if (!this.keepHistory) {
1342
+ completed = await this.queue.remove(task, leaseRemove);
1343
+ } else {
1344
+ completed = await this.queue.update(task, leaseUpdate);
1345
+ }
973
1346
 
974
- let completed = false;
975
- if (!this.keepHistory) {
976
- completed = await this.queue.remove(task, leaseRemove);
977
- } else {
978
- completed = await this.queue.update(task, leaseUpdate);
979
- }
1347
+ if (!completed) {
1348
+ this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
1349
+ return;
1350
+ }
980
1351
 
981
- if (!completed) {
982
- this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
1352
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
1353
+ task.isSent = true;
1354
+ task.isSending = false;
1355
+ task.sendingAt = 0;
1356
+ callHook('onSent', this.onSent, task, info);
983
1357
  return;
984
1358
  }
985
1359
 
986
- // Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
987
- task.isSent = true;
988
- task.isSending = false;
989
- task.sendingAt = 0;
990
- this.onSent(task, info);
991
- return;
992
- }
993
-
994
- if (task.tries >= this.maxTries) {
995
- this.___finalizeRejected(task, info);
1360
+ if (task.tries >= this.maxTries) {
1361
+ this.___finalizeRejected(task, info);
1362
+
1363
+ const leaseRemove = __leaseRemoveOpts(task);
1364
+ const leaseUpdate = __withLease(task, {
1365
+ isSent: false,
1366
+ isFailed: true,
1367
+ isSending: false,
1368
+ sendingAt: 0,
1369
+ mailOptions: task.mailOptions,
1370
+ });
1371
+
1372
+ let finalized = false;
1373
+ if (!this.keepHistory) {
1374
+ finalized = await this.queue.remove(task, leaseRemove);
1375
+ } else {
1376
+ finalized = await this.queue.update(task, leaseUpdate);
1377
+ }
996
1378
 
997
- const leaseRemove = __leaseRemoveOpts(task);
998
- const leaseUpdate = __withLease(task, {
999
- isSent: false,
1000
- isFailed: true,
1001
- isSending: false,
1002
- sendingAt: 0,
1003
- mailOptions: task.mailOptions,
1004
- });
1379
+ if (!finalized) {
1380
+ this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
1381
+ return;
1382
+ }
1005
1383
 
1006
- let finalized = false;
1007
- if (!this.keepHistory) {
1008
- finalized = await this.queue.remove(task, leaseRemove);
1009
- } else {
1010
- finalized = await this.queue.update(task, leaseUpdate);
1011
- }
1384
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
1385
+ task.isSent = false;
1386
+ task.isFailed = true;
1387
+ task.isSending = false;
1012
1388
 
1013
- if (!finalized) {
1014
- this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
1389
+ const rejectedAddrs = [];
1390
+ for (const mo of task.mailOptions) {
1391
+ for (const r of (mo.rejected || [])) {
1392
+ rejectedAddrs.push(r.address);
1393
+ }
1394
+ }
1395
+ const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
1396
+ this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
1397
+ callHook('onError', this.onError, partialError, task, info);
1015
1398
  return;
1016
1399
  }
1017
1400
 
1018
- // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
1019
- task.isSent = false;
1020
- task.isFailed = true;
1021
- task.isSending = false;
1022
-
1023
- const rejectedAddrs = [];
1024
- for (const mo of task.mailOptions) {
1025
- for (const r of (mo.rejected || [])) {
1026
- rejectedAddrs.push(r.address);
1027
- }
1401
+ const nextSendAt = Date.now() + this.retryDelay;
1402
+ const released = await this.queue.update(task, __withLease(task, {
1403
+ isSending: false,
1404
+ sendingAt: 0,
1405
+ sendAt: nextSendAt,
1406
+ mailOptions: task.mailOptions,
1407
+ }));
1408
+ if (!released) {
1409
+ this.__debug('[private send] lease lost before partial retry release', task.uuid);
1410
+ return;
1028
1411
  }
1029
- const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
1030
- this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
1031
- this.onError(partialError, task, info);
1032
- return;
1412
+ this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
1413
+ } catch (completionError) {
1414
+ logError('[private send] completion error after transport callback', completionError);
1415
+ } finally {
1416
+ resolve();
1033
1417
  }
1034
-
1035
- const nextSendAt = Date.now() + this.retryDelay;
1036
- const released = await this.queue.update(task, __withLease(task, {
1037
- isSending: false,
1038
- sendingAt: 0,
1039
- sendAt: nextSendAt,
1040
- mailOptions: task.mailOptions,
1041
- }));
1042
- if (!released) {
1043
- this.__debug('[private send] lease lost before partial retry release', task.uuid);
1044
- return;
1045
- }
1046
- this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
1047
- } finally {
1048
- resolve();
1049
- }
1418
+ });
1050
1419
  });
1051
- });
1420
+ } finally {
1421
+ // A transport that throws synchronously never reaches the callback above.
1422
+ await renewal.stop();
1423
+ }
1052
1424
  } catch (e) {
1425
+ if (this.__abortInFlight) {
1426
+ this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
1427
+ return;
1428
+ }
1053
1429
  logError('Exception during runtime:', e);
1054
1430
  await this.___handleError(task, e, {});
1055
1431
  }
@@ -1064,7 +1440,7 @@ class MailTime {
1064
1440
  */
1065
1441
  async ___iterate() {
1066
1442
  this.__debug('[private iterate]');
1067
- if (this.__isDestroyed) {
1443
+ if (this.__isDestroyed || this.__isPaused) {
1068
1444
  return;
1069
1445
  }
1070
1446
  const limit = this.mode === 'one' ? 1 : Infinity;
@@ -1131,7 +1507,7 @@ class MailTime {
1131
1507
  }
1132
1508
  this.__unhealthyTransports.add(r.index);
1133
1509
  logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
1134
- this.onError(r.error, null, { transportIndex: r.index, phase: 'verify' });
1510
+ callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
1135
1511
  }
1136
1512
 
1137
1513
  if (this.__unhealthyTransports.size === this.transports.length) {