mail-time 4.1.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
@@ -5,11 +5,56 @@ 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, hasOwnProp, 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
@@ -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,7 +301,34 @@ 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;
253
333
  this.__abortInFlight = false;
254
334
  this.__isPaused = false;
@@ -307,6 +387,8 @@ class MailTime {
307
387
  this.__debug(`INITIALIZING [mode: ${this.mode}]`);
308
388
  this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
309
389
  this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
390
+ this.__debug(`INITIALIZING [renewClaim: ${this.renewClaim}]`);
391
+ this.__debug(`INITIALIZING [strictPayload: ${this.strictPayload}]`);
310
392
 
311
393
  /** SERVER-SPECIFIC CHECKS AND CONFIG */
312
394
  if (this.type === 'server') {
@@ -371,6 +453,7 @@ class MailTime {
371
453
  };
372
454
  }
373
455
 
456
+ /** @type {MailTimeScheduler | undefined} */
374
457
  this.scheduler = new JoSk({
375
458
  debug: this.debug,
376
459
  ...this.josk,
@@ -536,12 +619,17 @@ class MailTime {
536
619
  * @returns {Promise<string>} uuid of the email
537
620
  * @throws {Error}
538
621
  */
539
- async sendMail(opts = {}) {
622
+ async sendMail(opts) {
623
+ opts = (opts && typeof opts === 'object') ? opts : {};
540
624
  this.__debug('[sendMail]', opts);
541
625
  if (!opts.html && !opts.text) {
542
626
  throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
543
627
  }
544
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
+
545
633
  let sendAt = opts.sendAt;
546
634
  if (sendAt instanceof Date) {
547
635
  sendAt = +sendAt;
@@ -670,13 +758,16 @@ class MailTime {
670
758
  task.isSending = false;
671
759
 
672
760
  this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
673
- this.onError(error, task, info);
761
+ callHook('onError', this.onError, error, task, info);
674
762
  return;
675
763
  }
676
764
 
677
765
  let transportIndex = task.transport;
678
766
 
679
- 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)) {
680
771
  transportIndex = this.___nextHealthyTransport(transportIndex);
681
772
  }
682
773
 
@@ -738,12 +829,18 @@ class MailTime {
738
829
  * @internal
739
830
  * @memberOf MailTime
740
831
  * @name ___render
741
- * @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.
742
838
  * @param {string} _string - Template with Mustache-like placeholders
743
839
  * @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
840
+ * @param {boolean} [isHtml=true] - `false` for text/plain bodies and header values
744
841
  * @returns {string}
745
842
  */
746
- ___render(_string, replacements) {
843
+ ___render(_string, replacements, isHtml = true) {
747
844
  let string = _string;
748
845
  const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
749
846
  if (matchHTML) {
@@ -760,7 +857,8 @@ class MailTime {
760
857
  for (let i = 0; i < matchStr.length; i++) {
761
858
  const key = matchStr[i].slice(2, -2).trim();
762
859
  if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
763
- string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
860
+ const value = `${replacements[key]}`;
861
+ string = string.replace(matchStr[i], isHtml ? escapeHtml(value) : value);
764
862
  }
765
863
  }
766
864
  }
@@ -799,7 +897,7 @@ class MailTime {
799
897
  const isMulti = mailOptionsList.length > 1;
800
898
 
801
899
  for (let i = 0; i < mailOptionsList.length; i++) {
802
- const mailOption = { ...mailOptionsList[i] };
900
+ const mailOption = this.___applyPayloadPolicy({ ...mailOptionsList[i] });
803
901
 
804
902
  if (mailOption.html) {
805
903
  const rendered = this.___render(mailOption.html, mailOption);
@@ -812,7 +910,7 @@ class MailTime {
812
910
  }
813
911
 
814
912
  if (mailOption.text) {
815
- const rendered = this.___render(mailOption.text, mailOption);
913
+ const rendered = this.___render(mailOption.text, mailOption, false);
816
914
  if (isMulti) {
817
915
  compiledOpts.text += '\r\n' + rendered;
818
916
  } else {
@@ -830,11 +928,14 @@ class MailTime {
830
928
 
831
929
  if (isMulti) {
832
930
  const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
833
- compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
931
+ compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length }, false);
834
932
  }
835
933
 
836
934
  if (!compiledOpts.from && this.from) {
837
- compiledOpts.from = this.from(transport);
935
+ compiledOpts.from = this.from(transport, {
936
+ index: this.transports.indexOf(transport),
937
+ from: MailTime.transportFrom(transport),
938
+ });
838
939
  }
839
940
 
840
941
  const acceptedSet = collectAcceptedSet(task);
@@ -844,9 +945,84 @@ class MailTime {
844
945
  compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
845
946
  }
846
947
 
948
+ if (this.strictPayload) {
949
+ compiledOpts.disableFileAccess = true;
950
+ compiledOpts.disableUrlAccess = true;
951
+ }
952
+
847
953
  return compiledOpts;
848
954
  }
849
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
+
850
1026
  /**
851
1027
  * @internal
852
1028
  * @memberOf MailTime
@@ -911,6 +1087,124 @@ class MailTime {
911
1087
  }
912
1088
  }
913
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
+
914
1208
  /**
915
1209
  * @async
916
1210
  * @internal
@@ -991,131 +1285,142 @@ class MailTime {
991
1285
  const transport = this.transports[transportIndex];
992
1286
 
993
1287
  const compiledOpts = this.___compileMailOpts(transport, task);
1288
+ const renewal = this.___startClaimRenewal(task);
994
1289
 
995
- await new Promise((resolve) => {
996
- transport.sendMail(compiledOpts, async (error, info) => {
997
- if (this.__abortInFlight) {
998
- this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
999
- resolve();
1000
- return;
1001
- }
1002
- this.__debug('[private send] [sending]', { error, info });
1003
- try {
1004
- if (error) {
1005
- 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();
1006
1299
  return;
1007
1300
  }
1301
+ this.__debug('[private send] [sending]', { error, info });
1302
+ try {
1303
+ if (error) {
1304
+ await this.___handleError(task, error, info);
1305
+ return;
1306
+ }
1008
1307
 
1009
- const acceptedAddrs = Array.isArray(info?.accepted)
1010
- ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
1011
- : [];
1308
+ const acceptedAddrs = Array.isArray(info?.accepted)
1309
+ ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
1310
+ : [];
1012
1311
 
1013
- if (acceptedAddrs.length === 0) {
1014
- await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
1015
- return;
1016
- }
1312
+ if (acceptedAddrs.length === 0) {
1313
+ await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
1314
+ return;
1315
+ }
1017
1316
 
1018
- this.___trackAcceptedRecipients(task, acceptedAddrs);
1317
+ this.___trackAcceptedRecipients(task, acceptedAddrs);
1019
1318
 
1020
- const allRecipients = collectAllRecipients(task);
1021
- const allAccepted = collectAcceptedSet(task);
1022
- let isFullyDelivered = true;
1023
- for (const addr of allRecipients) {
1024
- if (!allAccepted.has(addr)) {
1025
- isFullyDelivered = false;
1026
- 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
+ }
1027
1327
  }
1028
- }
1029
-
1030
- if (isFullyDelivered) {
1031
- this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
1032
1328
 
1033
- const leaseRemove = __leaseRemoveOpts(task);
1034
- const leaseUpdate = __withLease(task, {
1035
- isSent: true,
1036
- isSending: false,
1037
- sendingAt: 0,
1038
- mailOptions: task.mailOptions,
1039
- });
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
+ }
1040
1346
 
1041
- let completed = false;
1042
- if (!this.keepHistory) {
1043
- completed = await this.queue.remove(task, leaseRemove);
1044
- } else {
1045
- completed = await this.queue.update(task, leaseUpdate);
1046
- }
1347
+ if (!completed) {
1348
+ this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
1349
+ return;
1350
+ }
1047
1351
 
1048
- if (!completed) {
1049
- 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);
1050
1357
  return;
1051
1358
  }
1052
1359
 
1053
- // Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
1054
- task.isSent = true;
1055
- task.isSending = false;
1056
- task.sendingAt = 0;
1057
- this.onSent(task, info);
1058
- return;
1059
- }
1060
-
1061
- if (task.tries >= this.maxTries) {
1062
- 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
+ }
1063
1378
 
1064
- const leaseRemove = __leaseRemoveOpts(task);
1065
- const leaseUpdate = __withLease(task, {
1066
- isSent: false,
1067
- isFailed: true,
1068
- isSending: false,
1069
- sendingAt: 0,
1070
- mailOptions: task.mailOptions,
1071
- });
1379
+ if (!finalized) {
1380
+ this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
1381
+ return;
1382
+ }
1072
1383
 
1073
- let finalized = false;
1074
- if (!this.keepHistory) {
1075
- finalized = await this.queue.remove(task, leaseRemove);
1076
- } else {
1077
- finalized = await this.queue.update(task, leaseUpdate);
1078
- }
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;
1079
1388
 
1080
- if (!finalized) {
1081
- 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);
1082
1398
  return;
1083
1399
  }
1084
1400
 
1085
- // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
1086
- task.isSent = false;
1087
- task.isFailed = true;
1088
- task.isSending = false;
1089
-
1090
- const rejectedAddrs = [];
1091
- for (const mo of task.mailOptions) {
1092
- for (const r of (mo.rejected || [])) {
1093
- rejectedAddrs.push(r.address);
1094
- }
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;
1095
1411
  }
1096
- const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
1097
- this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
1098
- this.onError(partialError, task, info);
1099
- return;
1100
- }
1101
-
1102
- const nextSendAt = Date.now() + this.retryDelay;
1103
- const released = await this.queue.update(task, __withLease(task, {
1104
- isSending: false,
1105
- sendingAt: 0,
1106
- sendAt: nextSendAt,
1107
- mailOptions: task.mailOptions,
1108
- }));
1109
- if (!released) {
1110
- this.__debug('[private send] lease lost before partial retry release', task.uuid);
1111
- 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();
1112
1417
  }
1113
- this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
1114
- } finally {
1115
- resolve();
1116
- }
1418
+ });
1117
1419
  });
1118
- });
1420
+ } finally {
1421
+ // A transport that throws synchronously never reaches the callback above.
1422
+ await renewal.stop();
1423
+ }
1119
1424
  } catch (e) {
1120
1425
  if (this.__abortInFlight) {
1121
1426
  this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
@@ -1202,7 +1507,7 @@ class MailTime {
1202
1507
  }
1203
1508
  this.__unhealthyTransports.add(r.index);
1204
1509
  logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
1205
- this.onError(r.error, null, { transportIndex: r.index, phase: 'verify' });
1510
+ callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
1206
1511
  }
1207
1512
 
1208
1513
  if (this.__unhealthyTransports.size === this.transports.length) {