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.cjs CHANGED
@@ -225,6 +225,24 @@ const filterAddressField = (field, acceptedSet) => {
225
225
  return field;
226
226
  };
227
227
 
228
+ const HTML_ESCAPES = {
229
+ '&': '&',
230
+ '<': '&lt;',
231
+ '>': '&gt;',
232
+ '"': '&quot;',
233
+ "'": '&#39;',
234
+ };
235
+
236
+ /**
237
+ * @name escapeHtml - Escape the five HTML-significant characters.
238
+ * @function
239
+ * @param {string} value
240
+ * @returns {string} `value` safe to interpolate into HTML text or an attribute value.
241
+ */
242
+ const escapeHtml = (value) => {
243
+ return `${value}`.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
244
+ };
245
+
228
246
  /**
229
247
  * @name isSendClaimUpdate - Detect an atomic send-claim update (`{ isSending: true, tries: N }`).
230
248
  * @function
@@ -1855,7 +1873,7 @@ const PRESETS = Object.freeze({
1855
1873
  retries: 5,
1856
1874
  retryDelay: 2000,
1857
1875
  revolvingInterval: 1024,
1858
- sendingTimeout: 60_000,
1876
+ sendingTimeout: 120_000,
1859
1877
  mode: 'batch',
1860
1878
  concurrency: 4,
1861
1879
  onError: defaultPresetOnError,
@@ -1908,7 +1926,7 @@ const PRESETS = Object.freeze({
1908
1926
  retries: 20,
1909
1927
  retryDelay: 5000,
1910
1928
  revolvingInterval: 1024,
1911
- sendingTimeout: 60_000,
1929
+ sendingTimeout: 120_000,
1912
1930
  mode: 'batch',
1913
1931
  concurrency: 2,
1914
1932
  onError: defaultPresetOnError,
@@ -1975,6 +1993,51 @@ const mailTimePreset = (name, overrides) => {
1975
1993
  const noop = () => {};
1976
1994
  const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
1977
1995
 
1996
+ const callHook = (name, callback, ...args) => {
1997
+ try {
1998
+ const result = callback(...args);
1999
+ if (result && typeof result.then === 'function') {
2000
+ Promise.resolve(result).catch((hookError) => {
2001
+ logError(`[${name}] callback failed`, hookError);
2002
+ });
2003
+ }
2004
+ } catch (hookError) {
2005
+ logError(`[${name}] callback failed`, hookError);
2006
+ }
2007
+ };
2008
+
2009
+ /**
2010
+ * Floor below which `sendingTimeout` is likely shorter than a real SMTP roundtrip
2011
+ * (connect + greeting + STARTTLS + envelope + DATA, times MX rollover). A claim that
2012
+ * expires mid-send is re-claimed by a peer, which delivers the same letter twice.
2013
+ */
2014
+ const MIN_SAFE_SENDING_TIMEOUT = 120000;
2015
+ const DEFAULT_MAX_RENEWALS = 10;
2016
+
2017
+ /**
2018
+ * Fields a queued task may set under `{strictPayload: true}`. Everything else —
2019
+ * `attachments`, `envelope`, `dkim`, `raw`, `icalEvent`, … — is dropped, because a
2020
+ * process with queue-write access would otherwise inherit nodemailer's whole
2021
+ * capability surface (local file read, URL fetch, sender/DKIM override).
2022
+ */
2023
+ const STRICT_MAIL_FIELDS = [
2024
+ 'to',
2025
+ 'cc',
2026
+ 'bcc',
2027
+ 'replyTo',
2028
+ 'subject',
2029
+ 'text',
2030
+ 'html',
2031
+ 'headers',
2032
+ 'list',
2033
+ 'priority',
2034
+ 'encoding',
2035
+ 'textEncoding',
2036
+ 'inReplyTo',
2037
+ 'references',
2038
+ 'date',
2039
+ ];
2040
+
1978
2041
  const __withLease = (task, updateObj) => {
1979
2042
  if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
1980
2043
  return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
@@ -2093,7 +2156,7 @@ const buildRejectionErrorMap = (info) => {
2093
2156
  return map;
2094
2157
  };
2095
2158
 
2096
- 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>';
2159
+ 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>';
2097
2160
 
2098
2161
  /**
2099
2162
  * @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
@@ -2124,7 +2187,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2124
2187
  */
2125
2188
 
2126
2189
  /**
2127
- * @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
2190
+ * @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
2191
+ */
2192
+
2193
+ /**
2194
+ * @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
2128
2195
  */
2129
2196
 
2130
2197
  /**
@@ -2152,7 +2219,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2152
2219
  */
2153
2220
 
2154
2221
  /**
2155
- * @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
2222
+ * @typedef {{ index: number, from: string | undefined }} MailTimeFromDetails
2223
+ */
2224
+
2225
+ /**
2226
+ * @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
2156
2227
  */
2157
2228
 
2158
2229
  /** Class of MailTime */
@@ -2213,7 +2284,34 @@ class MailTime {
2213
2284
  this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
2214
2285
  this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
2215
2286
  this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
2216
- this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
2287
+ this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && Number.isFinite(opts.sendingTimeout) && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
2288
+ if (this.sendingTimeout < MIN_SAFE_SENDING_TIMEOUT) {
2289
+ 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.`);
2290
+ }
2291
+
2292
+ // Claim renewal: MailTime has no way to know how long a transport will take, so a
2293
+ // long-but-healthy send used to lose its lock to a "recovery" worker. While a send
2294
+ // is in flight the claim is re-stamped every {renewClaim} ms — bounded by
2295
+ // {maxRenewals} so a genuinely wedged send is still recovered, just later.
2296
+ if (opts.renewClaim === false || opts.renewClaim === 0) {
2297
+ this.renewClaim = 0;
2298
+ } else if (typeof opts.renewClaim === 'number' && Number.isFinite(opts.renewClaim) && opts.renewClaim > 0) {
2299
+ this.renewClaim = Math.floor(opts.renewClaim);
2300
+ } else {
2301
+ this.renewClaim = Math.max(1000, Math.floor(this.sendingTimeout / 3));
2302
+ }
2303
+ this.maxRenewals = (typeof opts.maxRenewals === 'number' && Number.isFinite(opts.maxRenewals) && opts.maxRenewals >= 0) ? Math.floor(opts.maxRenewals) : DEFAULT_MAX_RENEWALS;
2304
+
2305
+ // Transport fail-over policy. Rotating on *any* error can resubmit a letter the
2306
+ // MX already accepted; a transport that knows better says so.
2307
+ this.shouldFailOver = (typeof opts.shouldFailOver === 'function') ? opts.shouldFailOver.bind(this) : null;
2308
+
2309
+ this.strictPayload = opts.strictPayload === true;
2310
+ this.allowedMailFields = new Set([
2311
+ ...STRICT_MAIL_FIELDS,
2312
+ ...(Array.isArray(opts.allowedMailFields) ? opts.allowedMailFields.filter((field) => typeof field === 'string') : []),
2313
+ ]);
2314
+
2217
2315
  this.__isDestroyed = false;
2218
2316
  this.__abortInFlight = false;
2219
2317
  this.__isPaused = false;
@@ -2272,6 +2370,8 @@ class MailTime {
2272
2370
  this.__debug(`INITIALIZING [mode: ${this.mode}]`);
2273
2371
  this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
2274
2372
  this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
2373
+ this.__debug(`INITIALIZING [renewClaim: ${this.renewClaim}]`);
2374
+ this.__debug(`INITIALIZING [strictPayload: ${this.strictPayload}]`);
2275
2375
 
2276
2376
  /** SERVER-SPECIFIC CHECKS AND CONFIG */
2277
2377
  if (this.type === 'server') {
@@ -2336,6 +2436,7 @@ class MailTime {
2336
2436
  };
2337
2437
  }
2338
2438
 
2439
+ /** @type {MailTimeScheduler | undefined} */
2339
2440
  this.scheduler = new josk.JoSk({
2340
2441
  debug: this.debug,
2341
2442
  ...this.josk,
@@ -2501,12 +2602,17 @@ class MailTime {
2501
2602
  * @returns {Promise<string>} uuid of the email
2502
2603
  * @throws {Error}
2503
2604
  */
2504
- async sendMail(opts = {}) {
2605
+ async sendMail(opts) {
2606
+ opts = (opts && typeof opts === 'object') ? opts : {};
2505
2607
  this.__debug('[sendMail]', opts);
2506
2608
  if (!opts.html && !opts.text) {
2507
2609
  throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
2508
2610
  }
2509
2611
 
2612
+ if (opts.raw !== void 0) {
2613
+ 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.');
2614
+ }
2615
+
2510
2616
  let sendAt = opts.sendAt;
2511
2617
  if (sendAt instanceof Date) {
2512
2618
  sendAt = +sendAt;
@@ -2635,13 +2741,16 @@ class MailTime {
2635
2741
  task.isSending = false;
2636
2742
 
2637
2743
  this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
2638
- this.onError(error, task, info);
2744
+ callHook('onError', this.onError, error, task, info);
2639
2745
  return;
2640
2746
  }
2641
2747
 
2642
2748
  let transportIndex = task.transport;
2643
2749
 
2644
- if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) {
2750
+ if (this.strategy === 'backup'
2751
+ && this.transports.length > 1
2752
+ && (task.tries % this.failsToNext) === 0
2753
+ && this.___mayFailOver(error, info, task)) {
2645
2754
  transportIndex = this.___nextHealthyTransport(transportIndex);
2646
2755
  }
2647
2756
 
@@ -2703,12 +2812,18 @@ class MailTime {
2703
2812
  * @internal
2704
2813
  * @memberOf MailTime
2705
2814
  * @name ___render
2706
- * @description Render Mustache-like placeholders
2815
+ * @description Render Mustache-like placeholders. `{{{key}}}` is always verbatim.
2816
+ * `{{key}}` is HTML-escaped in HTML contexts (`html`, `template`, `concatDelimiter`) —
2817
+ * that is what double-brace means everywhere else, and the previous strip-tags pass was
2818
+ * not a safety net: it needs a closing `>`, so an unterminated `<a href="…` survived
2819
+ * into the rendered body. Text bodies and subject headers are not HTML, so `{{key}}`
2820
+ * substitutes verbatim there.
2707
2821
  * @param {string} _string - Template with Mustache-like placeholders
2708
2822
  * @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
2823
+ * @param {boolean} [isHtml=true] - `false` for text/plain bodies and header values
2709
2824
  * @returns {string}
2710
2825
  */
2711
- ___render(_string, replacements) {
2826
+ ___render(_string, replacements, isHtml = true) {
2712
2827
  let string = _string;
2713
2828
  const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
2714
2829
  if (matchHTML) {
@@ -2725,7 +2840,8 @@ class MailTime {
2725
2840
  for (let i = 0; i < matchStr.length; i++) {
2726
2841
  const key = matchStr[i].slice(2, -2).trim();
2727
2842
  if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
2728
- string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
2843
+ const value = `${replacements[key]}`;
2844
+ string = string.replace(matchStr[i], isHtml ? escapeHtml(value) : value);
2729
2845
  }
2730
2846
  }
2731
2847
  }
@@ -2764,7 +2880,7 @@ class MailTime {
2764
2880
  const isMulti = mailOptionsList.length > 1;
2765
2881
 
2766
2882
  for (let i = 0; i < mailOptionsList.length; i++) {
2767
- const mailOption = { ...mailOptionsList[i] };
2883
+ const mailOption = this.___applyPayloadPolicy({ ...mailOptionsList[i] });
2768
2884
 
2769
2885
  if (mailOption.html) {
2770
2886
  const rendered = this.___render(mailOption.html, mailOption);
@@ -2777,7 +2893,7 @@ class MailTime {
2777
2893
  }
2778
2894
 
2779
2895
  if (mailOption.text) {
2780
- const rendered = this.___render(mailOption.text, mailOption);
2896
+ const rendered = this.___render(mailOption.text, mailOption, false);
2781
2897
  if (isMulti) {
2782
2898
  compiledOpts.text += '\r\n' + rendered;
2783
2899
  } else {
@@ -2795,11 +2911,14 @@ class MailTime {
2795
2911
 
2796
2912
  if (isMulti) {
2797
2913
  const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
2798
- compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
2914
+ compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length }, false);
2799
2915
  }
2800
2916
 
2801
2917
  if (!compiledOpts.from && this.from) {
2802
- compiledOpts.from = this.from(transport);
2918
+ compiledOpts.from = this.from(transport, {
2919
+ index: this.transports.indexOf(transport),
2920
+ from: MailTime.transportFrom(transport),
2921
+ });
2803
2922
  }
2804
2923
 
2805
2924
  const acceptedSet = collectAcceptedSet(task);
@@ -2809,9 +2928,84 @@ class MailTime {
2809
2928
  compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
2810
2929
  }
2811
2930
 
2931
+ if (this.strictPayload) {
2932
+ compiledOpts.disableFileAccess = true;
2933
+ compiledOpts.disableUrlAccess = true;
2934
+ }
2935
+
2812
2936
  return compiledOpts;
2813
2937
  }
2814
2938
 
2939
+ /**
2940
+ * @internal
2941
+ * @memberOf MailTime
2942
+ * @name ___applyPayloadPolicy
2943
+ * @description Narrow one queued mail option to what a task is allowed to set. `raw` is
2944
+ * always dropped: it bypasses composition (so `template`, `concatEmails` and the
2945
+ * `from()` callback silently stop applying) and historically bypassed nodemailer's
2946
+ * `disableFileAccess`/`disableUrlAccess` with it. Under `{strictPayload: true}` only
2947
+ * `allowedMailFields` survive.
2948
+ * @param {MailTimeMailOptions} mailOption - a single queued mail option object
2949
+ * @returns {MailTimeMailOptions}
2950
+ */
2951
+ ___applyPayloadPolicy(mailOption) {
2952
+ if (mailOption.raw !== void 0) {
2953
+ delete mailOption.raw;
2954
+ logError('[___compileMailOpts] dropped {raw} from a queued task — `raw` bypasses composition and is never sent by MailTime');
2955
+ }
2956
+
2957
+ if (!this.strictPayload) {
2958
+ return mailOption;
2959
+ }
2960
+
2961
+ const allowed = {};
2962
+ for (const key of Object.keys(mailOption)) {
2963
+ if (this.allowedMailFields.has(key)) {
2964
+ allowed[key] = mailOption[key];
2965
+ } else {
2966
+ this.__debug('[___applyPayloadPolicy] dropped non-allowlisted field', key);
2967
+ }
2968
+ }
2969
+ return allowed;
2970
+ }
2971
+
2972
+ /**
2973
+ * @static
2974
+ * @memberOf MailTime
2975
+ * @name transportFrom
2976
+ * @description Best-effort sender address for a transport. `nodemailer.createTransport()`
2977
+ * only exposes `.options` for *plain-object* configs — for a class-instance transporter
2978
+ * (anything with its own `.send()`) nodemailer's internal `Mail.options` is always `{}`,
2979
+ * so `transport.options.from` silently reads `undefined`. This walks the places the
2980
+ * address can actually live.
2981
+ * @param {MailTimeTransport} transport
2982
+ * @returns {string | undefined}
2983
+ */
2984
+ static transportFrom(transport) {
2985
+ const candidates = [
2986
+ transport?.options?.from,
2987
+ transport?.transporter?.options?.from,
2988
+ transport?._defaults?.from,
2989
+ transport?._options?.from,
2990
+ ];
2991
+
2992
+ for (const candidate of candidates) {
2993
+ // nodemailer accepts `from` as a string or as `{ name, address }`. Resolve the
2994
+ // first candidate that yields a usable address — not the first truthy slot, so a
2995
+ // malformed `{ name }` object cannot mask a real address further down the chain.
2996
+ const address = (typeof candidate === 'string')
2997
+ ? candidate
2998
+ : (candidate && typeof candidate === 'object' && typeof candidate.address === 'string')
2999
+ ? candidate.address
3000
+ : '';
3001
+ if (address.trim()) {
3002
+ return address;
3003
+ }
3004
+ }
3005
+
3006
+ return void 0;
3007
+ }
3008
+
2815
3009
  /**
2816
3010
  * @internal
2817
3011
  * @memberOf MailTime
@@ -2876,6 +3070,124 @@ class MailTime {
2876
3070
  }
2877
3071
  }
2878
3072
 
3073
+ /**
3074
+ * @internal
3075
+ * @memberOf MailTime
3076
+ * @name ___mayFailOver
3077
+ * @description Whether this failure may be retried on the *next* transport. Rotating on
3078
+ * any error can resubmit a letter the receiving MTA already accepted (or may have
3079
+ * accepted — a socket that died during `DATA` is indistinguishable from one that died
3080
+ * before it). A transport signals "do not fail over" with `error.mayFailOver = false`;
3081
+ * the `shouldFailOver(error, info, task)` option overrides both.
3082
+ * @param {unknown} error - error returned by the transport
3083
+ * @param {object|undefined} info - info object returned by the transport
3084
+ * @param {MailTimeTask} task - email's task object from Storage
3085
+ * @returns {boolean}
3086
+ */
3087
+ ___mayFailOver(error, info, task) {
3088
+ if (this.shouldFailOver) {
3089
+ try {
3090
+ return this.shouldFailOver(error, info, task) !== false;
3091
+ } catch (policyError) {
3092
+ logError('[shouldFailOver] callback failed; keeping the current transport', policyError);
3093
+ return false;
3094
+ }
3095
+ }
3096
+ return !(error && typeof error === 'object' && error.mayFailOver === false);
3097
+ }
3098
+
3099
+ /**
3100
+ * @internal
3101
+ * @memberOf MailTime
3102
+ * @name ___startClaimRenewal
3103
+ * @description Hold a claim alive while its SMTP roundtrip runs. MailTime 4.x stamped
3104
+ * `sendingAt` once at claim time, so any send slower than `sendingTimeout` lost its lock
3105
+ * to a "recovery" worker and the letter went out twice. The renewal is a lease-guarded
3106
+ * update — it succeeds only while *this* worker still owns the row — and is bounded by
3107
+ * `maxRenewals` so a genuinely wedged send is still recovered.
3108
+ * @param {MailTimeTask} task - claimed task, with `isSending`/`sendingAt`/`tries` set
3109
+ * @returns {{ stop: () => Promise<void> }}
3110
+ */
3111
+ ___startClaimRenewal(task) {
3112
+ if (!this.renewClaim || !this.maxRenewals || this.type !== 'server') {
3113
+ return { stop: async () => {} };
3114
+ }
3115
+
3116
+ let stopped = false;
3117
+ let renewals = 0;
3118
+ let timer = null;
3119
+ let activeRenewal = null;
3120
+ const halt = () => {
3121
+ if (stopped) {
3122
+ return;
3123
+ }
3124
+ stopped = true;
3125
+ if (timer) {
3126
+ clearInterval(timer);
3127
+ timer = null;
3128
+ }
3129
+ };
3130
+ const stop = async () => {
3131
+ halt();
3132
+ if (activeRenewal) {
3133
+ await activeRenewal;
3134
+ }
3135
+ };
3136
+
3137
+ const renew = async () => {
3138
+ if (stopped || this.__isDestroyed || this.__abortInFlight) {
3139
+ halt();
3140
+ return;
3141
+ }
3142
+
3143
+ if (renewals >= this.maxRenewals) {
3144
+ this.__debug('[private renewClaim] renewal budget exhausted; letting the claim go stale', task.uuid);
3145
+ halt();
3146
+ return;
3147
+ }
3148
+ renewals += 1;
3149
+
3150
+ // Strictly greater than the current stamp: storages that report "modified" only on
3151
+ // an actual value change would otherwise read a same-millisecond renewal as a no-op.
3152
+ const renewedAt = Math.max(Date.now(), (typeof task.sendingAt === 'number' ? task.sendingAt : 0) + 1);
3153
+ let renewed = false;
3154
+ try {
3155
+ renewed = await this.queue.update(task, __withLease(task, {
3156
+ isSending: true,
3157
+ sendingAt: renewedAt,
3158
+ }));
3159
+ } catch (renewError) {
3160
+ logError('[private renewClaim] storage error during claim renewal', renewError);
3161
+ halt();
3162
+ return;
3163
+ }
3164
+
3165
+ if (!renewed) {
3166
+ this.__debug('[private renewClaim] lease lost; stopping renewal', task.uuid);
3167
+ halt();
3168
+ return;
3169
+ }
3170
+
3171
+ task.sendingAt = renewedAt;
3172
+ this.__debug(`[private renewClaim] renewed ${renewals}/${this.maxRenewals}`, task.uuid);
3173
+ };
3174
+
3175
+ timer = setInterval(() => {
3176
+ if (activeRenewal) {
3177
+ return;
3178
+ }
3179
+ activeRenewal = renew().finally(() => {
3180
+ activeRenewal = null;
3181
+ });
3182
+ }, this.renewClaim);
3183
+
3184
+ if (typeof timer.unref === 'function') {
3185
+ timer.unref();
3186
+ }
3187
+
3188
+ return { stop };
3189
+ }
3190
+
2879
3191
  /**
2880
3192
  * @async
2881
3193
  * @internal
@@ -2956,131 +3268,142 @@ class MailTime {
2956
3268
  const transport = this.transports[transportIndex];
2957
3269
 
2958
3270
  const compiledOpts = this.___compileMailOpts(transport, task);
3271
+ const renewal = this.___startClaimRenewal(task);
2959
3272
 
2960
- await new Promise((resolve) => {
2961
- transport.sendMail(compiledOpts, async (error, info) => {
2962
- if (this.__abortInFlight) {
2963
- this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
2964
- resolve();
2965
- return;
2966
- }
2967
- this.__debug('[private send] [sending]', { error, info });
2968
- try {
2969
- if (error) {
2970
- await this.___handleError(task, error, info);
3273
+ try {
3274
+ await new Promise((resolve) => {
3275
+ transport.sendMail(compiledOpts, async (error, info) => {
3276
+ // The roundtrip has settled — stop extending the lease before any completion
3277
+ // write, so the guard below sees the last `sendingAt` this worker persisted.
3278
+ await renewal.stop();
3279
+ if (this.__abortInFlight) {
3280
+ this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
3281
+ resolve();
2971
3282
  return;
2972
3283
  }
3284
+ this.__debug('[private send] [sending]', { error, info });
3285
+ try {
3286
+ if (error) {
3287
+ await this.___handleError(task, error, info);
3288
+ return;
3289
+ }
2973
3290
 
2974
- const acceptedAddrs = Array.isArray(info?.accepted)
2975
- ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
2976
- : [];
3291
+ const acceptedAddrs = Array.isArray(info?.accepted)
3292
+ ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
3293
+ : [];
2977
3294
 
2978
- if (acceptedAddrs.length === 0) {
2979
- await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
2980
- return;
2981
- }
3295
+ if (acceptedAddrs.length === 0) {
3296
+ await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
3297
+ return;
3298
+ }
2982
3299
 
2983
- this.___trackAcceptedRecipients(task, acceptedAddrs);
3300
+ this.___trackAcceptedRecipients(task, acceptedAddrs);
2984
3301
 
2985
- const allRecipients = collectAllRecipients(task);
2986
- const allAccepted = collectAcceptedSet(task);
2987
- let isFullyDelivered = true;
2988
- for (const addr of allRecipients) {
2989
- if (!allAccepted.has(addr)) {
2990
- isFullyDelivered = false;
2991
- break;
3302
+ const allRecipients = collectAllRecipients(task);
3303
+ const allAccepted = collectAcceptedSet(task);
3304
+ let isFullyDelivered = true;
3305
+ for (const addr of allRecipients) {
3306
+ if (!allAccepted.has(addr)) {
3307
+ isFullyDelivered = false;
3308
+ break;
3309
+ }
2992
3310
  }
2993
- }
2994
-
2995
- if (isFullyDelivered) {
2996
- this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
2997
3311
 
2998
- const leaseRemove = __leaseRemoveOpts(task);
2999
- const leaseUpdate = __withLease(task, {
3000
- isSent: true,
3001
- isSending: false,
3002
- sendingAt: 0,
3003
- mailOptions: task.mailOptions,
3004
- });
3312
+ if (isFullyDelivered) {
3313
+ this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
3314
+
3315
+ const leaseRemove = __leaseRemoveOpts(task);
3316
+ const leaseUpdate = __withLease(task, {
3317
+ isSent: true,
3318
+ isSending: false,
3319
+ sendingAt: 0,
3320
+ mailOptions: task.mailOptions,
3321
+ });
3322
+
3323
+ let completed = false;
3324
+ if (!this.keepHistory) {
3325
+ completed = await this.queue.remove(task, leaseRemove);
3326
+ } else {
3327
+ completed = await this.queue.update(task, leaseUpdate);
3328
+ }
3005
3329
 
3006
- let completed = false;
3007
- if (!this.keepHistory) {
3008
- completed = await this.queue.remove(task, leaseRemove);
3009
- } else {
3010
- completed = await this.queue.update(task, leaseUpdate);
3011
- }
3330
+ if (!completed) {
3331
+ this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
3332
+ return;
3333
+ }
3012
3334
 
3013
- if (!completed) {
3014
- this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
3335
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
3336
+ task.isSent = true;
3337
+ task.isSending = false;
3338
+ task.sendingAt = 0;
3339
+ callHook('onSent', this.onSent, task, info);
3015
3340
  return;
3016
3341
  }
3017
3342
 
3018
- // Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
3019
- task.isSent = true;
3020
- task.isSending = false;
3021
- task.sendingAt = 0;
3022
- this.onSent(task, info);
3023
- return;
3024
- }
3025
-
3026
- if (task.tries >= this.maxTries) {
3027
- this.___finalizeRejected(task, info);
3343
+ if (task.tries >= this.maxTries) {
3344
+ this.___finalizeRejected(task, info);
3345
+
3346
+ const leaseRemove = __leaseRemoveOpts(task);
3347
+ const leaseUpdate = __withLease(task, {
3348
+ isSent: false,
3349
+ isFailed: true,
3350
+ isSending: false,
3351
+ sendingAt: 0,
3352
+ mailOptions: task.mailOptions,
3353
+ });
3354
+
3355
+ let finalized = false;
3356
+ if (!this.keepHistory) {
3357
+ finalized = await this.queue.remove(task, leaseRemove);
3358
+ } else {
3359
+ finalized = await this.queue.update(task, leaseUpdate);
3360
+ }
3028
3361
 
3029
- const leaseRemove = __leaseRemoveOpts(task);
3030
- const leaseUpdate = __withLease(task, {
3031
- isSent: false,
3032
- isFailed: true,
3033
- isSending: false,
3034
- sendingAt: 0,
3035
- mailOptions: task.mailOptions,
3036
- });
3362
+ if (!finalized) {
3363
+ this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
3364
+ return;
3365
+ }
3037
3366
 
3038
- let finalized = false;
3039
- if (!this.keepHistory) {
3040
- finalized = await this.queue.remove(task, leaseRemove);
3041
- } else {
3042
- finalized = await this.queue.update(task, leaseUpdate);
3043
- }
3367
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
3368
+ task.isSent = false;
3369
+ task.isFailed = true;
3370
+ task.isSending = false;
3044
3371
 
3045
- if (!finalized) {
3046
- this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
3372
+ const rejectedAddrs = [];
3373
+ for (const mo of task.mailOptions) {
3374
+ for (const r of (mo.rejected || [])) {
3375
+ rejectedAddrs.push(r.address);
3376
+ }
3377
+ }
3378
+ const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
3379
+ this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
3380
+ callHook('onError', this.onError, partialError, task, info);
3047
3381
  return;
3048
3382
  }
3049
3383
 
3050
- // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
3051
- task.isSent = false;
3052
- task.isFailed = true;
3053
- task.isSending = false;
3054
-
3055
- const rejectedAddrs = [];
3056
- for (const mo of task.mailOptions) {
3057
- for (const r of (mo.rejected || [])) {
3058
- rejectedAddrs.push(r.address);
3059
- }
3384
+ const nextSendAt = Date.now() + this.retryDelay;
3385
+ const released = await this.queue.update(task, __withLease(task, {
3386
+ isSending: false,
3387
+ sendingAt: 0,
3388
+ sendAt: nextSendAt,
3389
+ mailOptions: task.mailOptions,
3390
+ }));
3391
+ if (!released) {
3392
+ this.__debug('[private send] lease lost before partial retry release', task.uuid);
3393
+ return;
3060
3394
  }
3061
- const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
3062
- this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
3063
- this.onError(partialError, task, info);
3064
- return;
3065
- }
3066
-
3067
- const nextSendAt = Date.now() + this.retryDelay;
3068
- const released = await this.queue.update(task, __withLease(task, {
3069
- isSending: false,
3070
- sendingAt: 0,
3071
- sendAt: nextSendAt,
3072
- mailOptions: task.mailOptions,
3073
- }));
3074
- if (!released) {
3075
- this.__debug('[private send] lease lost before partial retry release', task.uuid);
3076
- return;
3395
+ this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
3396
+ } catch (completionError) {
3397
+ logError('[private send] completion error after transport callback', completionError);
3398
+ } finally {
3399
+ resolve();
3077
3400
  }
3078
- this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
3079
- } finally {
3080
- resolve();
3081
- }
3401
+ });
3082
3402
  });
3083
- });
3403
+ } finally {
3404
+ // A transport that throws synchronously never reaches the callback above.
3405
+ await renewal.stop();
3406
+ }
3084
3407
  } catch (e) {
3085
3408
  if (this.__abortInFlight) {
3086
3409
  this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
@@ -3167,7 +3490,7 @@ class MailTime {
3167
3490
  }
3168
3491
  this.__unhealthyTransports.add(r.index);
3169
3492
  logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
3170
- this.onError(r.error, null, { transportIndex: r.index, phase: 'verify' });
3493
+ callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
3171
3494
  }
3172
3495
 
3173
3496
  if (this.__unhealthyTransports.size === this.transports.length) {