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.cjs CHANGED
@@ -1,20 +1,48 @@
1
1
  'use strict';
2
2
 
3
3
  const josk = require('josk');
4
- const node_crypto = require('node:crypto');
4
+ const crypto = require('crypto');
5
5
 
6
6
  const hasOwn = Object.prototype.hasOwnProperty;
7
7
 
8
+ /**
9
+ * @name hasOwnProp - `Object.hasOwn` (ES2022) polyfill.
10
+ * @function
11
+ * @param {object} obj
12
+ * @param {PropertyKey} key
13
+ * @returns {boolean} `true` if the object has the property, `false` otherwise.
14
+ */
15
+ const hasOwnProp = (obj, key) => hasOwn.call(obj, key);
16
+
17
+ /**
18
+ * @name debug - Debug logging.
19
+ * @function
20
+ * @param {boolean} isDebug
21
+ * @param {...any} args
22
+ * @returns {void}
23
+ */
8
24
  const debug = (isDebug, ...args) => {
9
25
  if (isDebug) {
10
26
  console.info('[DEBUG] [mail-time]', `${new Date()}`, ...args);
11
27
  }
12
28
  };
13
29
 
30
+ /**
31
+ * @name logError - Error logging.
32
+ * @function
33
+ * @param {...any} args
34
+ * @returns {void}
35
+ */
14
36
  const logError = (...args) => {
15
37
  console.error('[ERROR] [mail-time]', `${new Date()}`, ...args);
16
38
  };
17
39
 
40
+ /**
41
+ * @name isPlainObject - Check whether a value is a plain object (literal or `Object.create(null)`).
42
+ * @function
43
+ * @param {any} value
44
+ * @returns {boolean} `true` for plain objects, `false` for `null`, arrays, class instances, and primitives.
45
+ */
18
46
  const isPlainObject = (value) => {
19
47
  if (value === null || typeof value !== 'object') {
20
48
  return false;
@@ -24,11 +52,15 @@ const isPlainObject = (value) => {
24
52
  };
25
53
 
26
54
  /**
27
- * Minimal deep-merge sufficient for nodemailer-shaped mail options:
55
+ * @name deepMerge - Minimal deep-merge sufficient for nodemailer-shaped mail options:
28
56
  * - plain objects merge key-by-key
29
57
  * - arrays concatenate
30
58
  * - other values (strings, numbers, Date, Buffer, streams, classes) replace
31
59
  * Source values override target values.
60
+ * @function
61
+ * @param {any} target - Base value; merged into a shallow clone when a plain object, otherwise ignored.
62
+ * @param {any} source - Overriding value; returns `target` unchanged when not a plain object.
63
+ * @returns {any} The merged result (a new object), or `target` when `source` is not a plain object.
32
64
  */
33
65
  const deepMerge = (target, source) => {
34
66
  if (!isPlainObject(source)) {
@@ -54,9 +86,13 @@ const deepMerge = (target, source) => {
54
86
  };
55
87
 
56
88
  /**
57
- * Order-insensitive deep equality. Treats arrays as multisets and
89
+ * @name equals - Order-insensitive deep equality. Treats arrays as multisets and
58
90
  * objects as unordered maps. Designed for the small `mailOptions`
59
91
  * shape used by MailTime's email concatenation dedup.
92
+ * @function
93
+ * @param {any} a
94
+ * @param {any} b
95
+ * @returns {boolean} `true` when `a` and `b` are deeply equal ignoring array/key order.
60
96
  */
61
97
  const equals = (a, b) => {
62
98
  if (a === b) {
@@ -115,9 +151,11 @@ const equals = (a, b) => {
115
151
  };
116
152
 
117
153
  /**
118
- * Extract the email part of a nodemailer-shaped recipient entry.
154
+ * @name extractEmail - Extract the email part of a nodemailer-shaped recipient entry.
119
155
  * Accepts `'a@x.com'`, `'Name <a@x.com>'`, or `{ name, address }`.
120
- * Returns the address lowercased, or `null` when none can be parsed.
156
+ * @function
157
+ * @param {string|{name?: string, address?: string}|null|undefined} entry
158
+ * @returns {string|null} The address lowercased and trimmed, or `null` when none can be parsed.
121
159
  */
122
160
  const extractEmail = (entry) => {
123
161
  if (!entry) {
@@ -134,7 +172,10 @@ const extractEmail = (entry) => {
134
172
  };
135
173
 
136
174
  /**
137
- * Normalize a `to`/`cc`/`bcc` field into a flat list of lowercase addresses.
175
+ * @name toAddressList - Normalize a `to`/`cc`/`bcc` field into a flat list of lowercase addresses.
176
+ * @function
177
+ * @param {string|Array<string|{name?: string, address?: string}>|null|undefined} field
178
+ * @returns {string[]} Flat list of parsed lowercase addresses; empty when `field` is falsy or unparseable.
138
179
  */
139
180
  const toAddressList = (field) => {
140
181
  if (!field) {
@@ -155,9 +196,13 @@ const toAddressList = (field) => {
155
196
  };
156
197
 
157
198
  /**
158
- * Remove entries whose extracted address is in `acceptedSet` from a
159
- * nodemailer `to`/`cc`/`bcc` field. Returns `void 0` when the filtered
160
- * array would be empty or the single string is dropped.
199
+ * @name filterAddressField - Remove entries whose extracted address is in `acceptedSet` from a
200
+ * nodemailer `to`/`cc`/`bcc` field.
201
+ * @function
202
+ * @param {string|Array<string|{name?: string, address?: string}>|null|undefined} field
203
+ * @param {Set<string>} acceptedSet - Lowercase addresses to drop.
204
+ * @returns {string|Array|undefined} The filtered field, or `void 0` when the filtered array would be
205
+ * empty or the single string is dropped. Returns `field` unchanged when `acceptedSet` is empty.
161
206
  */
162
207
  const filterAddressField = (field, acceptedSet) => {
163
208
  if (!field || !(acceptedSet instanceof Set) || acceptedSet.size === 0) {
@@ -180,19 +225,61 @@ const filterAddressField = (field, acceptedSet) => {
180
225
  return field;
181
226
  };
182
227
 
228
+ const HTML_ESCAPES = {
229
+ '&': '&amp;',
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
+
246
+ /**
247
+ * @name isSendClaimUpdate - Detect an atomic send-claim update (`{ isSending: true, tries: N }`).
248
+ * @function
249
+ * @param {object} updateObj
250
+ * @returns {boolean} `true` when `updateObj` claims a row for sending.
251
+ */
183
252
  const isSendClaimUpdate = (updateObj) => {
184
253
  return updateObj && updateObj.isSending === true && typeof updateObj.tries === 'number';
185
254
  };
186
255
 
256
+ /**
257
+ * @name isSendLeaseGuardedUpdate - Detect an update guarded by a send lease
258
+ * (carries `leaseTries` and `leaseSendingAt`).
259
+ * @function
260
+ * @param {object} updateObj
261
+ * @returns {boolean} `true` when both lease guard fields are present numbers.
262
+ */
187
263
  const isSendLeaseGuardedUpdate = (updateObj) => {
188
264
  return updateObj && typeof updateObj.leaseTries === 'number' && typeof updateObj.leaseSendingAt === 'number';
189
265
  };
190
266
 
267
+ /**
268
+ * @name isAppendMailOptionUpdate - Detect an update that appends a mail option (for email concatenation).
269
+ * @function
270
+ * @param {object} updateObj
271
+ * @returns {boolean} `true` when `updateObj.appendMailOption` is set.
272
+ */
191
273
  const isAppendMailOptionUpdate = (updateObj) => {
192
274
  return updateObj && updateObj.appendMailOption !== void 0;
193
275
  };
194
276
 
195
- /** Strip MailTime-internal update keys before persisting to storage. */
277
+ /**
278
+ * @name stripInternalUpdateMeta - Strip MailTime-internal update keys before persisting to storage.
279
+ * @function
280
+ * @param {object} updateObj
281
+ * @returns {object} A shallow clone without `leaseTries`, `leaseSendingAt`, and `appendMailOption`.
282
+ */
196
283
  const stripInternalUpdateMeta = (updateObj) => {
197
284
  const out = { ...updateObj };
198
285
  delete out.leaseTries;
@@ -201,6 +288,13 @@ const stripInternalUpdateMeta = (updateObj) => {
201
288
  return out;
202
289
  };
203
290
 
291
+ /**
292
+ * @name isSendLeaseRemove - Detect a remove guarded by a send lease
293
+ * (carries `leaseTries` and `leaseSendingAt`).
294
+ * @function
295
+ * @param {object} opts
296
+ * @returns {boolean} `true` when both lease guard fields are present numbers.
297
+ */
204
298
  const isSendLeaseRemove = (opts) => {
205
299
  return opts && typeof opts.leaseTries === 'number' && typeof opts.leaseSendingAt === 'number';
206
300
  };
@@ -425,10 +519,13 @@ class MongoQueue {
425
519
  cursor.limit(limit);
426
520
  }
427
521
 
428
- while (await cursor.hasNext()) {
429
- await this.mailTimeInstance.___dispatch(await cursor.next());
522
+ try {
523
+ while (await cursor.hasNext()) {
524
+ await this.mailTimeInstance.___dispatch(await cursor.next());
525
+ }
526
+ } finally {
527
+ await cursor.close().catch(() => {});
430
528
  }
431
- await cursor.close();
432
529
  } catch (iterateError) {
433
530
  logError('[iterate] [while/await] [iterateError]', iterateError);
434
531
  }
@@ -1197,7 +1294,21 @@ class RedisQueue {
1197
1294
  */
1198
1295
 
1199
1296
  const DEFAULT_PREFIX = 'default';
1200
- const setupLockId = 93824519;
1297
+
1298
+ // Two-key advisory lock, mirroring josk's PostgresAdapter: a stable MailTime namespace
1299
+ // plus a per-prefix hash. `pg_advisory_lock(int4, int4)` lives in its own keyspace, isolated
1300
+ // from single-int callers in the same database; distinct prefixes get distinct lock IDs, so
1301
+ // co-tenant MailTime queues no longer serialize each other's schema setup.
1302
+ const ADVISORY_LOCK_NAMESPACE = 0x4D61696C; // 'Mail' in ASCII as int32
1303
+
1304
+ /**
1305
+ * @internal
1306
+ * @param {string} prefix
1307
+ * @returns {number} signed int32 hash of the prefix string
1308
+ */
1309
+ const advisoryLockKeyFor = (prefix) => {
1310
+ return crypto.createHash('sha256').update(prefix).digest().readInt32BE(0);
1311
+ };
1201
1312
 
1202
1313
  const fieldMap = {
1203
1314
  to: 'to_address',
@@ -1300,7 +1411,8 @@ class PostgresQueue {
1300
1411
 
1301
1412
  /** @internal */
1302
1413
  async __setup() {
1303
- await this.client.query('SELECT pg_advisory_lock($1)', [setupLockId]);
1414
+ const advisoryLockKey = advisoryLockKeyFor(this.prefix);
1415
+ await this.client.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1304
1416
 
1305
1417
  try {
1306
1418
  await this.client.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
@@ -1331,7 +1443,7 @@ class PostgresQueue {
1331
1443
  await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_pending_to
1332
1444
  ON mail_time_queue (prefix, to_address, is_sent, is_failed, is_cancelled, send_at)`);
1333
1445
  } finally {
1334
- await this.client.query('SELECT pg_advisory_unlock($1)', [setupLockId]);
1446
+ await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1335
1447
  }
1336
1448
  }
1337
1449
 
@@ -1713,7 +1825,7 @@ function defaultPresetOnError(error, email, info) {
1713
1825
  * @property {number} [sendingTimeout]
1714
1826
  * @property {'one' | 'batch'} [mode]
1715
1827
  * @property {number} [concurrency]
1716
- * @property {(error: unknown, email: object, details?: object) => void} [onError]
1828
+ * @property {(error: unknown, email: any, details?: object) => void} [onError]
1717
1829
  * @property {object} [josk]
1718
1830
  */
1719
1831
 
@@ -1761,7 +1873,7 @@ const PRESETS = Object.freeze({
1761
1873
  retries: 5,
1762
1874
  retryDelay: 2000,
1763
1875
  revolvingInterval: 1024,
1764
- sendingTimeout: 60_000,
1876
+ sendingTimeout: 120_000,
1765
1877
  mode: 'batch',
1766
1878
  concurrency: 4,
1767
1879
  onError: defaultPresetOnError,
@@ -1814,7 +1926,7 @@ const PRESETS = Object.freeze({
1814
1926
  retries: 20,
1815
1927
  retryDelay: 5000,
1816
1928
  revolvingInterval: 1024,
1817
- sendingTimeout: 60_000,
1929
+ sendingTimeout: 120_000,
1818
1930
  mode: 'batch',
1819
1931
  concurrency: 2,
1820
1932
  onError: defaultPresetOnError,
@@ -1863,12 +1975,12 @@ const presetNames = Object.freeze(/** @type {MailTimePresetName[]} */ (Object.ke
1863
1975
  *
1864
1976
  * @param {MailTimePresetName} name - one of `presetNames`
1865
1977
  * @param {object} [overrides] - additional MailTime constructor options
1866
- * @returns {object} fresh, mutable MailTime constructor options
1978
+ * @returns {MailTimePresetConfig} fresh, mutable MailTime constructor options (preset deep-cloned + overrides merged)
1867
1979
  * @throws {Error} when `name` is unknown
1868
1980
  * @throws {TypeError} when `overrides` is provided but not a plain object
1869
1981
  */
1870
1982
  const mailTimePreset = (name, overrides) => {
1871
- if (typeof name !== 'string' || !Object.hasOwn(PRESETS, name)) {
1983
+ if (typeof name !== 'string' || !hasOwnProp(PRESETS, name)) {
1872
1984
  throw new Error(`[mail-time] [mailTimePreset] unknown preset "${name}". Available: ${presetNames.join(', ')}`);
1873
1985
  }
1874
1986
  if (overrides !== void 0 && !isPlainObject(overrides)) {
@@ -1881,6 +1993,51 @@ const mailTimePreset = (name, overrides) => {
1881
1993
  const noop = () => {};
1882
1994
  const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
1883
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
+
1884
2041
  const __withLease = (task, updateObj) => {
1885
2042
  if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
1886
2043
  return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
@@ -1999,7 +2156,7 @@ const buildRejectionErrorMap = (info) => {
1999
2156
  return map;
2000
2157
  };
2001
2158
 
2002
- 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>';
2003
2160
 
2004
2161
  /**
2005
2162
  * @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
@@ -2010,7 +2167,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2010
2167
  */
2011
2168
 
2012
2169
  /**
2013
- * @typedef {{ status: string, code: number, statusCode: number, error?: unknown }} MailTimePingResult
2170
+ * @typedef {{ status: string, code: number, statusCode: number, paused?: boolean, error?: unknown }} MailTimePingResult
2014
2171
  */
2015
2172
 
2016
2173
  /**
@@ -2030,7 +2187,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2030
2187
  */
2031
2188
 
2032
2189
  /**
2033
- * @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
2034
2195
  */
2035
2196
 
2036
2197
  /**
@@ -2058,7 +2219,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2058
2219
  */
2059
2220
 
2060
2221
  /**
2061
- * @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
2062
2227
  */
2063
2228
 
2064
2229
  /** Class of MailTime */
@@ -2119,8 +2284,37 @@ class MailTime {
2119
2284
  this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
2120
2285
  this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
2121
2286
  this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
2122
- 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
+
2123
2315
  this.__isDestroyed = false;
2316
+ this.__abortInFlight = false;
2317
+ this.__isPaused = false;
2124
2318
  this.__readyPromise = null;
2125
2319
  this.__schedulerTimer = null;
2126
2320
  this.__inFlight = new Set();
@@ -2176,6 +2370,8 @@ class MailTime {
2176
2370
  this.__debug(`INITIALIZING [mode: ${this.mode}]`);
2177
2371
  this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
2178
2372
  this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
2373
+ this.__debug(`INITIALIZING [renewClaim: ${this.renewClaim}]`);
2374
+ this.__debug(`INITIALIZING [strictPayload: ${this.strictPayload}]`);
2179
2375
 
2180
2376
  /** SERVER-SPECIFIC CHECKS AND CONFIG */
2181
2377
  if (this.type === 'server') {
@@ -2240,6 +2436,7 @@ class MailTime {
2240
2436
  };
2241
2437
  }
2242
2438
 
2439
+ /** @type {MailTimeScheduler | undefined} */
2243
2440
  this.scheduler = new josk.JoSk({
2244
2441
  debug: this.debug,
2245
2442
  ...this.josk,
@@ -2273,10 +2470,11 @@ class MailTime {
2273
2470
  if (this.scheduler) {
2274
2471
  const schedulerPing = await this.scheduler.ping();
2275
2472
  if (schedulerPing.status !== 'OK') {
2276
- return schedulerPing;
2473
+ return { ...schedulerPing, paused: this.__isPaused };
2277
2474
  }
2278
2475
  }
2279
- return await this.queue.ping();
2476
+ const queuePing = await this.queue.ping();
2477
+ return { ...queuePing, paused: this.__isPaused };
2280
2478
  }
2281
2479
 
2282
2480
  /**
@@ -2294,7 +2492,7 @@ class MailTime {
2294
2492
  /**
2295
2493
  * @memberOf MailTime
2296
2494
  * @name destroy
2297
- * @description Stop the scheduler and block future dispatches. Pass `{ drain: true }` to also wait for in-flight SMTP attempts (returns a Promise).
2495
+ * @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).
2298
2496
  * @param {{ drain?: boolean }} [opts]
2299
2497
  * @returns {boolean | Promise<boolean>}
2300
2498
  */
@@ -2305,12 +2503,21 @@ class MailTime {
2305
2503
  }
2306
2504
 
2307
2505
  this.__isDestroyed = true;
2506
+ this.__isPaused = false;
2308
2507
  if (this.scheduler && typeof this.scheduler.destroy === 'function') {
2309
2508
  this.scheduler.destroy();
2310
2509
  }
2311
2510
  if (opts && opts.drain === true && this.__pool) {
2312
2511
  return this.__pool.drain().then(() => true);
2313
2512
  }
2513
+ // No graceful drain requested: neutralize in-flight SMTP completions so a
2514
+ // destroyed instance performs no storage writes, no `onSent`/`onError`
2515
+ // callbacks, and no logging after teardown. Each claimed row keeps its
2516
+ // `isSending` lock, which a peer or a future instance recovers once
2517
+ // `sendingTimeout` elapses — identical to a crash mid-send. Use
2518
+ // `destroy({ drain: true })` (or `await drain()` before `destroy()`) when
2519
+ // in-flight sends must run to completion.
2520
+ this.__abortInFlight = true;
2314
2521
  return true;
2315
2522
  }
2316
2523
 
@@ -2328,6 +2535,52 @@ class MailTime {
2328
2535
  }
2329
2536
  }
2330
2537
 
2538
+ /**
2539
+ * @memberOf MailTime
2540
+ * @name pause
2541
+ * @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();`.
2542
+ * @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
2543
+ */
2544
+ pause() {
2545
+ this.__debug('[pause]');
2546
+ if (this.__isDestroyed || !this.scheduler) {
2547
+ return false;
2548
+ }
2549
+ const paused = this.scheduler.pause();
2550
+ if (paused) {
2551
+ this.__isPaused = true;
2552
+ }
2553
+ return paused;
2554
+ }
2555
+
2556
+ /**
2557
+ * @memberOf MailTime
2558
+ * @name resume
2559
+ * @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.
2560
+ * @returns {boolean} `true` if newly resumed; `false` if not paused, a client instance, or destroyed
2561
+ */
2562
+ resume() {
2563
+ this.__debug('[resume]');
2564
+ if (this.__isDestroyed || !this.scheduler) {
2565
+ return false;
2566
+ }
2567
+ const resumed = this.scheduler.resume();
2568
+ if (resumed) {
2569
+ this.__isPaused = false;
2570
+ }
2571
+ return resumed;
2572
+ }
2573
+
2574
+ /**
2575
+ * @memberOf MailTime
2576
+ * @name isPaused
2577
+ * @description Whether this instance is currently paused from draining the queue. Always `false` on `client` instances.
2578
+ * @returns {boolean}
2579
+ */
2580
+ get isPaused() {
2581
+ return this.__isPaused;
2582
+ }
2583
+
2331
2584
  /**
2332
2585
  * @memberOf MailTime
2333
2586
  * @name send
@@ -2349,12 +2602,17 @@ class MailTime {
2349
2602
  * @returns {Promise<string>} uuid of the email
2350
2603
  * @throws {Error}
2351
2604
  */
2352
- async sendMail(opts = {}) {
2605
+ async sendMail(opts) {
2606
+ opts = (opts && typeof opts === 'object') ? opts : {};
2353
2607
  this.__debug('[sendMail]', opts);
2354
2608
  if (!opts.html && !opts.text) {
2355
2609
  throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
2356
2610
  }
2357
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
+
2358
2616
  let sendAt = opts.sendAt;
2359
2617
  if (sendAt instanceof Date) {
2360
2618
  sendAt = +sendAt;
@@ -2445,6 +2703,10 @@ class MailTime {
2445
2703
  */
2446
2704
  async ___handleError(task, error, info) {
2447
2705
  this.__debug('[private handleError]', { task, error, info });
2706
+ if (this.__abortInFlight) {
2707
+ this.__debug('[private handleError] instance destroyed; skipping onError', task?.uuid);
2708
+ return;
2709
+ }
2448
2710
  if (!task) {
2449
2711
  return;
2450
2712
  }
@@ -2479,13 +2741,16 @@ class MailTime {
2479
2741
  task.isSending = false;
2480
2742
 
2481
2743
  this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
2482
- this.onError(error, task, info);
2744
+ callHook('onError', this.onError, error, task, info);
2483
2745
  return;
2484
2746
  }
2485
2747
 
2486
2748
  let transportIndex = task.transport;
2487
2749
 
2488
- 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)) {
2489
2754
  transportIndex = this.___nextHealthyTransport(transportIndex);
2490
2755
  }
2491
2756
 
@@ -2521,7 +2786,7 @@ class MailTime {
2521
2786
  this.transport = transportIndex;
2522
2787
  }
2523
2788
  const task = {
2524
- uuid: node_crypto.randomUUID(),
2789
+ uuid: crypto.randomUUID(),
2525
2790
  tries: 0,
2526
2791
  isSent: false,
2527
2792
  sendAt: opts.sendAt,
@@ -2547,18 +2812,24 @@ class MailTime {
2547
2812
  * @internal
2548
2813
  * @memberOf MailTime
2549
2814
  * @name ___render
2550
- * @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.
2551
2821
  * @param {string} _string - Template with Mustache-like placeholders
2552
2822
  * @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
2823
+ * @param {boolean} [isHtml=true] - `false` for text/plain bodies and header values
2553
2824
  * @returns {string}
2554
2825
  */
2555
- ___render(_string, replacements) {
2826
+ ___render(_string, replacements, isHtml = true) {
2556
2827
  let string = _string;
2557
2828
  const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
2558
2829
  if (matchHTML) {
2559
2830
  for (let i = 0; i < matchHTML.length; i++) {
2560
2831
  const key = matchHTML[i].slice(3, -3).trim();
2561
- if (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
2832
+ if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
2562
2833
  string = string.replace(matchHTML[i], `${replacements[key]}`);
2563
2834
  }
2564
2835
  }
@@ -2568,8 +2839,9 @@ class MailTime {
2568
2839
  if (matchStr) {
2569
2840
  for (let i = 0; i < matchStr.length; i++) {
2570
2841
  const key = matchStr[i].slice(2, -2).trim();
2571
- if (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
2572
- string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
2842
+ if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
2843
+ const value = `${replacements[key]}`;
2844
+ string = string.replace(matchStr[i], isHtml ? escapeHtml(value) : value);
2573
2845
  }
2574
2846
  }
2575
2847
  }
@@ -2608,7 +2880,7 @@ class MailTime {
2608
2880
  const isMulti = mailOptionsList.length > 1;
2609
2881
 
2610
2882
  for (let i = 0; i < mailOptionsList.length; i++) {
2611
- const mailOption = { ...mailOptionsList[i] };
2883
+ const mailOption = this.___applyPayloadPolicy({ ...mailOptionsList[i] });
2612
2884
 
2613
2885
  if (mailOption.html) {
2614
2886
  const rendered = this.___render(mailOption.html, mailOption);
@@ -2621,7 +2893,7 @@ class MailTime {
2621
2893
  }
2622
2894
 
2623
2895
  if (mailOption.text) {
2624
- const rendered = this.___render(mailOption.text, mailOption);
2896
+ const rendered = this.___render(mailOption.text, mailOption, false);
2625
2897
  if (isMulti) {
2626
2898
  compiledOpts.text += '\r\n' + rendered;
2627
2899
  } else {
@@ -2639,11 +2911,14 @@ class MailTime {
2639
2911
 
2640
2912
  if (isMulti) {
2641
2913
  const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
2642
- compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
2914
+ compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length }, false);
2643
2915
  }
2644
2916
 
2645
2917
  if (!compiledOpts.from && this.from) {
2646
- compiledOpts.from = this.from(transport);
2918
+ compiledOpts.from = this.from(transport, {
2919
+ index: this.transports.indexOf(transport),
2920
+ from: MailTime.transportFrom(transport),
2921
+ });
2647
2922
  }
2648
2923
 
2649
2924
  const acceptedSet = collectAcceptedSet(task);
@@ -2653,9 +2928,84 @@ class MailTime {
2653
2928
  compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
2654
2929
  }
2655
2930
 
2931
+ if (this.strictPayload) {
2932
+ compiledOpts.disableFileAccess = true;
2933
+ compiledOpts.disableUrlAccess = true;
2934
+ }
2935
+
2656
2936
  return compiledOpts;
2657
2937
  }
2658
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
+
2659
3009
  /**
2660
3010
  * @internal
2661
3011
  * @memberOf MailTime
@@ -2720,6 +3070,124 @@ class MailTime {
2720
3070
  }
2721
3071
  }
2722
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
+
2723
3191
  /**
2724
3192
  * @async
2725
3193
  * @internal
@@ -2800,127 +3268,147 @@ class MailTime {
2800
3268
  const transport = this.transports[transportIndex];
2801
3269
 
2802
3270
  const compiledOpts = this.___compileMailOpts(transport, task);
3271
+ const renewal = this.___startClaimRenewal(task);
2803
3272
 
2804
- await new Promise((resolve) => {
2805
- transport.sendMail(compiledOpts, async (error, info) => {
2806
- this.__debug('[private send] [sending]', { error, info });
2807
- try {
2808
- if (error) {
2809
- 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();
2810
3282
  return;
2811
3283
  }
3284
+ this.__debug('[private send] [sending]', { error, info });
3285
+ try {
3286
+ if (error) {
3287
+ await this.___handleError(task, error, info);
3288
+ return;
3289
+ }
2812
3290
 
2813
- const acceptedAddrs = Array.isArray(info?.accepted)
2814
- ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
2815
- : [];
3291
+ const acceptedAddrs = Array.isArray(info?.accepted)
3292
+ ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
3293
+ : [];
2816
3294
 
2817
- if (acceptedAddrs.length === 0) {
2818
- await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
2819
- return;
2820
- }
3295
+ if (acceptedAddrs.length === 0) {
3296
+ await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
3297
+ return;
3298
+ }
2821
3299
 
2822
- this.___trackAcceptedRecipients(task, acceptedAddrs);
3300
+ this.___trackAcceptedRecipients(task, acceptedAddrs);
2823
3301
 
2824
- const allRecipients = collectAllRecipients(task);
2825
- const allAccepted = collectAcceptedSet(task);
2826
- let isFullyDelivered = true;
2827
- for (const addr of allRecipients) {
2828
- if (!allAccepted.has(addr)) {
2829
- isFullyDelivered = false;
2830
- 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
+ }
2831
3310
  }
2832
- }
2833
3311
 
2834
- if (isFullyDelivered) {
2835
- this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
2836
-
2837
- const leaseRemove = __leaseRemoveOpts(task);
2838
- const leaseUpdate = __withLease(task, {
2839
- isSent: true,
2840
- isSending: false,
2841
- sendingAt: 0,
2842
- mailOptions: task.mailOptions,
2843
- });
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
+ }
2844
3329
 
2845
- let completed = false;
2846
- if (!this.keepHistory) {
2847
- completed = await this.queue.remove(task, leaseRemove);
2848
- } else {
2849
- completed = await this.queue.update(task, leaseUpdate);
2850
- }
3330
+ if (!completed) {
3331
+ this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
3332
+ return;
3333
+ }
2851
3334
 
2852
- if (!completed) {
2853
- 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);
2854
3340
  return;
2855
3341
  }
2856
3342
 
2857
- // Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
2858
- task.isSent = true;
2859
- task.isSending = false;
2860
- task.sendingAt = 0;
2861
- this.onSent(task, info);
2862
- return;
2863
- }
2864
-
2865
- if (task.tries >= this.maxTries) {
2866
- 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
+ }
2867
3361
 
2868
- const leaseRemove = __leaseRemoveOpts(task);
2869
- const leaseUpdate = __withLease(task, {
2870
- isSent: false,
2871
- isFailed: true,
2872
- isSending: false,
2873
- sendingAt: 0,
2874
- mailOptions: task.mailOptions,
2875
- });
3362
+ if (!finalized) {
3363
+ this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
3364
+ return;
3365
+ }
2876
3366
 
2877
- let finalized = false;
2878
- if (!this.keepHistory) {
2879
- finalized = await this.queue.remove(task, leaseRemove);
2880
- } else {
2881
- finalized = await this.queue.update(task, leaseUpdate);
2882
- }
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;
2883
3371
 
2884
- if (!finalized) {
2885
- 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);
2886
3381
  return;
2887
3382
  }
2888
3383
 
2889
- // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
2890
- task.isSent = false;
2891
- task.isFailed = true;
2892
- task.isSending = false;
2893
-
2894
- const rejectedAddrs = [];
2895
- for (const mo of task.mailOptions) {
2896
- for (const r of (mo.rejected || [])) {
2897
- rejectedAddrs.push(r.address);
2898
- }
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;
2899
3394
  }
2900
- const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
2901
- this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
2902
- this.onError(partialError, task, info);
2903
- return;
2904
- }
2905
-
2906
- const nextSendAt = Date.now() + this.retryDelay;
2907
- const released = await this.queue.update(task, __withLease(task, {
2908
- isSending: false,
2909
- sendingAt: 0,
2910
- sendAt: nextSendAt,
2911
- mailOptions: task.mailOptions,
2912
- }));
2913
- if (!released) {
2914
- this.__debug('[private send] lease lost before partial retry release', task.uuid);
2915
- 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();
2916
3400
  }
2917
- this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
2918
- } finally {
2919
- resolve();
2920
- }
3401
+ });
2921
3402
  });
2922
- });
3403
+ } finally {
3404
+ // A transport that throws synchronously never reaches the callback above.
3405
+ await renewal.stop();
3406
+ }
2923
3407
  } catch (e) {
3408
+ if (this.__abortInFlight) {
3409
+ this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
3410
+ return;
3411
+ }
2924
3412
  logError('Exception during runtime:', e);
2925
3413
  await this.___handleError(task, e, {});
2926
3414
  }
@@ -2935,7 +3423,7 @@ class MailTime {
2935
3423
  */
2936
3424
  async ___iterate() {
2937
3425
  this.__debug('[private iterate]');
2938
- if (this.__isDestroyed) {
3426
+ if (this.__isDestroyed || this.__isPaused) {
2939
3427
  return;
2940
3428
  }
2941
3429
  const limit = this.mode === 'one' ? 1 : Infinity;
@@ -3002,7 +3490,7 @@ class MailTime {
3002
3490
  }
3003
3491
  this.__unhealthyTransports.add(r.index);
3004
3492
  logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
3005
- this.onError(r.error, null, { transportIndex: r.index, phase: 'verify' });
3493
+ callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
3006
3494
  }
3007
3495
 
3008
3496
  if (this.__unhealthyTransports.size === this.transports.length) {