mail-time 4.1.0 → 5.1.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
@@ -711,7 +729,11 @@ class MongoQueue {
711
729
  * @property {(key: string, value: string, options?: object) => Promise<unknown>} set
712
730
  * @property {(key: string|string[]) => Promise<number>} del
713
731
  * @property {() => Promise<string>} ping
714
- * @property {(options: object) => AsyncIterable<string|string[]>} scanIterator
732
+ * @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
733
+ * @property {(key: string, field: string) => Promise<string|null>} [hGet]
734
+ * @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
735
+ * @property {(sha: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [evalSha]
736
+ * @property {(script: string) => Promise<string>} [scriptLoad]
715
737
  * @property {(key: string) => Promise<unknown>} [watch]
716
738
  * @property {() => Promise<unknown>} [unwatch]
717
739
  * @property {() => object} [multi]
@@ -721,10 +743,168 @@ class MongoQueue {
721
743
  * @typedef {object} RedisQueueOption
722
744
  * @property {RedisClient} client
723
745
  * @property {string} [prefix]
746
+ * @property {boolean} [useHashTags] - Use Redis Cluster hash-tag keys (`mailtime:{prefix}:*`). Default keeps existing standalone keys.
724
747
  */
725
748
 
726
749
  const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
727
750
  const DEFAULT_PREFIX$1 = 'default';
751
+ const VALID_PREFIX = /^[A-Za-z0-9_\-:.]+$/;
752
+ const TAGGED_ITERATE_LIMIT = 100;
753
+
754
+ const PUSH_TAGGED_TASK_SCRIPT = `
755
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
756
+ redis.call('ZADD', KEYS[2], tonumber(ARGV[3]), ARGV[1])
757
+ if #KEYS > 3 then
758
+ redis.call('SET', KEYS[3], ARGV[1], 'PXAT', tonumber(ARGV[4]))
759
+ redis.call('ZADD', KEYS[4], tonumber(ARGV[4]), KEYS[3])
760
+ end
761
+ return 1
762
+ `;
763
+
764
+ const ITERATE_TAGGED_TASKS_SCRIPT = `
765
+ local now = tonumber(ARGV[1])
766
+ local maxTries = tonumber(ARGV[2])
767
+ local sendingTimeout = tonumber(ARGV[3])
768
+ local limit = tonumber(ARGV[4])
769
+ local scanLimit = tonumber(ARGV[5])
770
+ local tasks = {}
771
+ if #KEYS > 2 then
772
+ local expiredPointers = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now, 'LIMIT', 0, 100)
773
+ for _, pointerKey in ipairs(expiredPointers) do
774
+ redis.call('DEL', pointerKey)
775
+ redis.call('ZREM', KEYS[3], pointerKey)
776
+ end
777
+ end
778
+ local due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'LIMIT', 0, scanLimit)
779
+ for _, uuid in ipairs(due) do
780
+ local payload = redis.call('HGET', KEYS[1], uuid)
781
+ if not payload then
782
+ redis.call('ZREM', KEYS[2], uuid)
783
+ else
784
+ local task = cjson.decode(payload)
785
+ if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) >= maxTries then
786
+ redis.call('ZREM', KEYS[2], uuid)
787
+ elseif task.isSending then
788
+ local eligibleAt = tonumber(task.sendingAt or 0) + sendingTimeout
789
+ if eligibleAt > now then
790
+ redis.call('ZADD', KEYS[2], eligibleAt, uuid)
791
+ else
792
+ table.insert(tasks, task)
793
+ end
794
+ elseif tonumber(task.sendAt or 0) <= now then
795
+ table.insert(tasks, task)
796
+ else
797
+ redis.call('ZADD', KEYS[2], tonumber(task.sendAt), uuid)
798
+ end
799
+ if #tasks >= limit then
800
+ break
801
+ end
802
+ end
803
+ end
804
+
805
+ return cjson.encode(tasks)
806
+ `;
807
+
808
+ const UPDATE_TAGGED_TASK_SCRIPT = `
809
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
810
+ if not payload then
811
+ redis.call('ZREM', KEYS[2], ARGV[1])
812
+ return 0
813
+ end
814
+
815
+ local task = cjson.decode(payload)
816
+ local update = cjson.decode(ARGV[2])
817
+ local mode = ARGV[3]
818
+ local now = tonumber(ARGV[4])
819
+ local sendingTimeout = tonumber(ARGV[5])
820
+ local expectedTries = tonumber(ARGV[6])
821
+ local leaseSendingAt = tonumber(ARGV[7])
822
+
823
+ if mode == 'claim' then
824
+ if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) ~= expectedTries then
825
+ return 0
826
+ end
827
+ if task.isSending and tonumber(task.sendingAt or 0) > now - sendingTimeout then
828
+ return 0
829
+ end
830
+ elseif mode == 'lease' then
831
+ if task.isCancelled or task.isFailed or not task.isSending
832
+ or tonumber(task.tries or 0) ~= expectedTries
833
+ or tonumber(task.sendingAt or 0) ~= leaseSendingAt then
834
+ return 0
835
+ end
836
+ end
837
+
838
+ for key, value in pairs(update) do
839
+ task[key] = value
840
+ end
841
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
842
+
843
+ if task.isSent or task.isFailed or task.isCancelled then
844
+ redis.call('ZREM', KEYS[2], ARGV[1])
845
+ if #KEYS > 3 then
846
+ local concatUuid = redis.call('GET', KEYS[3])
847
+ if concatUuid == ARGV[1] then
848
+ redis.call('DEL', KEYS[3])
849
+ redis.call('ZREM', KEYS[4], KEYS[3])
850
+ elseif not concatUuid then
851
+ redis.call('ZREM', KEYS[4], KEYS[3])
852
+ end
853
+ end
854
+ elseif task.isSending then
855
+ redis.call('ZADD', KEYS[2], tonumber(task.sendingAt or now) + sendingTimeout, ARGV[1])
856
+ else
857
+ redis.call('ZADD', KEYS[2], tonumber(task.sendAt), ARGV[1])
858
+ end
859
+ return 1
860
+ `;
861
+
862
+ const APPEND_TAGGED_MAIL_OPTION_SCRIPT = `
863
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
864
+ if not payload then
865
+ return 0
866
+ end
867
+ local task = cjson.decode(payload)
868
+ if task.isSending or task.isSent or task.isFailed or task.isCancelled then
869
+ return 0
870
+ end
871
+ task.mailOptions = task.mailOptions or {}
872
+ table.insert(task.mailOptions, cjson.decode(ARGV[2]))
873
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
874
+ return 1
875
+ `;
876
+
877
+ const REMOVE_TAGGED_TASK_SCRIPT = `
878
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
879
+ if not payload then
880
+ redis.call('ZREM', KEYS[2], ARGV[1])
881
+ return 0
882
+ end
883
+ local task = cjson.decode(payload)
884
+ if ARGV[2] == 'lease' and (task.isCancelled or task.isFailed or not task.isSending
885
+ or tonumber(task.tries or 0) ~= tonumber(ARGV[3])
886
+ or tonumber(task.sendingAt or 0) ~= tonumber(ARGV[4])) then
887
+ return 0
888
+ end
889
+ redis.call('HDEL', KEYS[1], ARGV[1])
890
+ redis.call('ZREM', KEYS[2], ARGV[1])
891
+ if #KEYS > 3 then
892
+ local concatUuid = redis.call('GET', KEYS[3])
893
+ if concatUuid == ARGV[1] then
894
+ redis.call('DEL', KEYS[3])
895
+ redis.call('ZREM', KEYS[4], KEYS[3])
896
+ elseif not concatUuid then
897
+ redis.call('ZREM', KEYS[4], KEYS[3])
898
+ end
899
+ end
900
+ return 1
901
+ `;
902
+
903
+ const sha1Hex = (string) => crypto.createHash('sha1').update(string).digest('hex');
904
+
905
+ const isNoScriptError = (error) => {
906
+ return !!error && (error.code === 'NOSCRIPT' || (typeof error.message === 'string' && error.message.includes('NOSCRIPT')));
907
+ };
728
908
 
729
909
  const canReleaseLease = (currentTask, updateObj) => {
730
910
  return currentTask
@@ -798,7 +978,21 @@ class RedisQueue {
798
978
  throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
799
979
  }
800
980
 
981
+ if (opts.useHashTags !== undefined && typeof opts.useHashTags !== 'boolean') {
982
+ throw new TypeError(`[mail-time] [RedisQueue] {useHashTags} option must be a boolean (received: ${typeof opts.useHashTags})`);
983
+ }
984
+
801
985
  this.client = opts.client;
986
+ this.useHashTags = opts.useHashTags === true;
987
+ this.__scriptSources = {
988
+ push: PUSH_TAGGED_TASK_SCRIPT,
989
+ iterate: ITERATE_TAGGED_TASKS_SCRIPT,
990
+ update: UPDATE_TAGGED_TASK_SCRIPT,
991
+ append: APPEND_TAGGED_MAIL_OPTION_SCRIPT,
992
+ remove: REMOVE_TAGGED_TASK_SCRIPT,
993
+ };
994
+ this.__scriptShas = Object.fromEntries(Object.entries(this.__scriptSources).map(([name, source]) => [name, sha1Hex(source)]));
995
+ this.__loadedShas = new Set();
802
996
  if (typeof opts.prefix === 'string') {
803
997
  this.__applyPrefix(opts.prefix);
804
998
  }
@@ -806,8 +1000,16 @@ class RedisQueue {
806
1000
 
807
1001
  /** @internal */
808
1002
  __applyPrefix(prefix) {
1003
+ if (this.useHashTags && !VALID_PREFIX.test(prefix)) {
1004
+ throw new Error(`[mail-time] [RedisQueue] {prefix} option must match ${VALID_PREFIX} when {useHashTags} is true (received: "${prefix}")`);
1005
+ }
809
1006
  this.prefix = prefix;
810
- this.uniqueName = `mailtime:${prefix}`;
1007
+ this.uniqueName = this.useHashTags ? `mailtime:{${prefix}}` : `mailtime:${prefix}`;
1008
+ if (this.useHashTags) {
1009
+ this.lettersKey = `${this.uniqueName}:letters`;
1010
+ this.scheduleKey = `${this.uniqueName}:schedule`;
1011
+ this.concatKeysKey = `${this.uniqueName}:concatkeys`;
1012
+ }
811
1013
  }
812
1014
 
813
1015
  /** @internal */
@@ -822,6 +1024,49 @@ class RedisQueue {
822
1024
  debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
823
1025
  }
824
1026
 
1027
+ /** @internal */
1028
+ async __runScript(scriptKey, options) {
1029
+ const source = this.__scriptSources[scriptKey];
1030
+ const sha = this.__scriptShas[scriptKey];
1031
+ if (!source || !sha) {
1032
+ throw new Error(`[mail-time] [RedisQueue] unknown script "${scriptKey}"`);
1033
+ }
1034
+
1035
+ if (this.__loadedShas.has(sha) && typeof this.client.evalSha === 'function') {
1036
+ try {
1037
+ return await this.client.evalSha(sha, options);
1038
+ } catch (error) {
1039
+ if (!isNoScriptError(error)) {
1040
+ throw error;
1041
+ }
1042
+ this.__loadedShas.delete(sha);
1043
+ }
1044
+ }
1045
+
1046
+ if (typeof this.client.scriptLoad === 'function' && typeof this.client.evalSha === 'function') {
1047
+ try {
1048
+ await this.client.scriptLoad(source);
1049
+ this.__loadedShas.add(sha);
1050
+ return await this.client.evalSha(sha, options);
1051
+ } catch (error) {
1052
+ if (!isNoScriptError(error)) {
1053
+ this.__debug(`[script:${scriptKey}] scriptLoad failed; falling back to EVAL`, error);
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
+ if (typeof this.client.eval !== 'function') {
1059
+ throw new Error('[mail-time] [RedisQueue] Redis Cluster client must support EVAL');
1060
+ }
1061
+ return await this.client.eval(source, options);
1062
+ }
1063
+
1064
+ /** @internal */
1065
+ __getTaggedConcatKey(to) {
1066
+ this.__ensurePrefix();
1067
+ return `${this.uniqueName}:concatletter:${to}`;
1068
+ }
1069
+
825
1070
  /**
826
1071
  * @async
827
1072
  * @memberOf RedisQueue
@@ -899,6 +1144,25 @@ class RedisQueue {
899
1144
  const maxTries = (this.mailTimeInstance && typeof this.mailTimeInstance.maxTries === 'number')
900
1145
  ? this.mailTimeInstance.maxTries
901
1146
  : 60;
1147
+
1148
+ if (this.useHashTags) {
1149
+ this.__ensurePrefix();
1150
+ const dispatchLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TAGGED_ITERATE_LIMIT;
1151
+ const payload = await this.__runScript('iterate', {
1152
+ keys: [this.lettersKey, this.scheduleKey, this.concatKeysKey],
1153
+ arguments: [`${now}`, `${maxTries}`, `${sendingTimeout}`, `${dispatchLimit}`, `${Math.max(dispatchLimit * 20, 100)}`],
1154
+ });
1155
+ const candidates = payload ? JSON.parse(String(payload)) : [];
1156
+ if (!Array.isArray(candidates)) {
1157
+ return;
1158
+ }
1159
+ for (const candidate of candidates) {
1160
+ if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
1161
+ await this.mailTimeInstance.___dispatch(candidate);
1162
+ }
1163
+ }
1164
+ return;
1165
+ }
902
1166
  let dispatched = 0;
903
1167
 
904
1168
  const matchPattern = this.__getKey('*', 'sendat');
@@ -955,14 +1219,15 @@ class RedisQueue {
955
1219
  return null;
956
1220
  }
957
1221
 
958
- const concatKey = this.__getKey(to, 'concatletter');
1222
+ const concatKey = this.useHashTags ? this.__getTaggedConcatKey(to) : this.__getKey(to, 'concatletter');
959
1223
  const uuid = await this.client.get(concatKey);
960
1224
  if (!uuid) {
961
1225
  return null;
962
1226
  }
963
1227
 
964
- const letterKey = this.__getKey(uuid, 'letter');
965
- const taskJSON = await this.client.get(letterKey);
1228
+ const taskJSON = this.useHashTags
1229
+ ? await this.client.hGet(this.lettersKey, uuid)
1230
+ : await this.client.get(this.__getKey(uuid, 'letter'));
966
1231
  if (!taskJSON) {
967
1232
  return null;
968
1233
  }
@@ -993,6 +1258,19 @@ class RedisQueue {
993
1258
  task.sendAt = +task.sendAt;
994
1259
  }
995
1260
 
1261
+ if (this.useHashTags) {
1262
+ this.__ensurePrefix();
1263
+ const keys = [this.lettersKey, this.scheduleKey];
1264
+ const args = [task.uuid, JSON.stringify(task), `${+task.sendAt}`];
1265
+ if (task.to) {
1266
+ keys.push(this.__getTaggedConcatKey(task.to));
1267
+ keys.push(this.concatKeysKey);
1268
+ args.push(`${task.sendAt - 128}`);
1269
+ }
1270
+ await this.__runScript('push', { keys, arguments: args });
1271
+ return;
1272
+ }
1273
+
996
1274
  const letterKey = this.__getKey(task.uuid, 'letter');
997
1275
  const sendatKey = this.__getKey(task.uuid, 'sendat');
998
1276
  const taskJSON = JSON.stringify(task);
@@ -1033,9 +1311,14 @@ class RedisQueue {
1033
1311
  return false;
1034
1312
  }
1035
1313
 
1036
- await this.client.del(this.__getKey(uuid, 'sendat'));
1037
- const letterKey = this.__getKey(uuid, 'letter');
1038
- const taskJSON = await this.client.get(letterKey);
1314
+ if (this.useHashTags) {
1315
+ this.__ensurePrefix();
1316
+ } else {
1317
+ await this.client.del(this.__getKey(uuid, 'sendat'));
1318
+ }
1319
+ const taskJSON = this.useHashTags
1320
+ ? await this.client.hGet(this.lettersKey, uuid)
1321
+ : await this.client.get(this.__getKey(uuid, 'letter'));
1039
1322
  if (!taskJSON) {
1040
1323
  return false;
1041
1324
  }
@@ -1069,6 +1352,30 @@ class RedisQueue {
1069
1352
  return false;
1070
1353
  }
1071
1354
 
1355
+ if (this.useHashTags) {
1356
+ this.__ensurePrefix();
1357
+ try {
1358
+ const keys = [this.lettersKey, this.scheduleKey];
1359
+ if (task.to) {
1360
+ keys.push(this.__getTaggedConcatKey(task.to));
1361
+ keys.push(this.concatKeysKey);
1362
+ }
1363
+ const result = await this.__runScript('remove', {
1364
+ keys,
1365
+ arguments: [
1366
+ task.uuid,
1367
+ isSendLeaseRemove(opts) ? 'lease' : 'plain',
1368
+ `${opts?.leaseTries || 0}`,
1369
+ `${opts?.leaseSendingAt || 0}`,
1370
+ ],
1371
+ });
1372
+ return Number(result) >= 1;
1373
+ } catch (opError) {
1374
+ logError('[remove] [tagged] [opError]', opError);
1375
+ return false;
1376
+ }
1377
+ }
1378
+
1072
1379
  const letterKey = this.__getKey(task.uuid, 'letter');
1073
1380
  if (isSendLeaseRemove(opts)) {
1074
1381
  if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
@@ -1143,6 +1450,36 @@ class RedisQueue {
1143
1450
  const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
1144
1451
 
1145
1452
  try {
1453
+ if (this.useHashTags) {
1454
+ if (isAppend) {
1455
+ const result = await this.__runScript('append', {
1456
+ keys: [this.lettersKey],
1457
+ arguments: [task.uuid, JSON.stringify(updateObj.appendMailOption)],
1458
+ });
1459
+ return Number(result) >= 1;
1460
+ }
1461
+
1462
+ const mode = isClaim ? 'claim' : (isLeaseRelease ? 'lease' : 'plain');
1463
+ const keys = [this.lettersKey, this.scheduleKey];
1464
+ if (task.to) {
1465
+ keys.push(this.__getTaggedConcatKey(task.to));
1466
+ keys.push(this.concatKeysKey);
1467
+ }
1468
+ const result = await this.__runScript('update', {
1469
+ keys,
1470
+ arguments: [
1471
+ task.uuid,
1472
+ JSON.stringify(stripInternalUpdateMeta(updateObj)),
1473
+ mode,
1474
+ `${now}`,
1475
+ `${sendingTimeout}`,
1476
+ `${isClaim ? task.tries : (updateObj.leaseTries || 0)}`,
1477
+ `${updateObj.leaseSendingAt || 0}`,
1478
+ ],
1479
+ });
1480
+ return Number(result) >= 1;
1481
+ }
1482
+
1146
1483
  if (isAppend) {
1147
1484
  if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
1148
1485
  if (!RedisQueue.__atomicAppendWarned) {
@@ -1254,6 +1591,15 @@ class RedisQueue {
1254
1591
  throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
1255
1592
  }
1256
1593
  this.__ensurePrefix();
1594
+ if (this.useHashTags) {
1595
+ if (type === 'letter') {
1596
+ return this.lettersKey;
1597
+ }
1598
+ if (type === 'sendat') {
1599
+ return this.scheduleKey;
1600
+ }
1601
+ return this.__getTaggedConcatKey(uuid);
1602
+ }
1257
1603
  return `${this.uniqueName}:${type}:${uuid}`;
1258
1604
  }
1259
1605
  }
@@ -1855,7 +2201,7 @@ const PRESETS = Object.freeze({
1855
2201
  retries: 5,
1856
2202
  retryDelay: 2000,
1857
2203
  revolvingInterval: 1024,
1858
- sendingTimeout: 60_000,
2204
+ sendingTimeout: 120_000,
1859
2205
  mode: 'batch',
1860
2206
  concurrency: 4,
1861
2207
  onError: defaultPresetOnError,
@@ -1908,7 +2254,7 @@ const PRESETS = Object.freeze({
1908
2254
  retries: 20,
1909
2255
  retryDelay: 5000,
1910
2256
  revolvingInterval: 1024,
1911
- sendingTimeout: 60_000,
2257
+ sendingTimeout: 120_000,
1912
2258
  mode: 'batch',
1913
2259
  concurrency: 2,
1914
2260
  onError: defaultPresetOnError,
@@ -1975,6 +2321,51 @@ const mailTimePreset = (name, overrides) => {
1975
2321
  const noop = () => {};
1976
2322
  const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
1977
2323
 
2324
+ const callHook = (name, callback, ...args) => {
2325
+ try {
2326
+ const result = callback(...args);
2327
+ if (result && typeof result.then === 'function') {
2328
+ Promise.resolve(result).catch((hookError) => {
2329
+ logError(`[${name}] callback failed`, hookError);
2330
+ });
2331
+ }
2332
+ } catch (hookError) {
2333
+ logError(`[${name}] callback failed`, hookError);
2334
+ }
2335
+ };
2336
+
2337
+ /**
2338
+ * Floor below which `sendingTimeout` is likely shorter than a real SMTP roundtrip
2339
+ * (connect + greeting + STARTTLS + envelope + DATA, times MX rollover). A claim that
2340
+ * expires mid-send is re-claimed by a peer, which delivers the same letter twice.
2341
+ */
2342
+ const MIN_SAFE_SENDING_TIMEOUT = 120000;
2343
+ const DEFAULT_MAX_RENEWALS = 10;
2344
+
2345
+ /**
2346
+ * Fields a queued task may set under `{strictPayload: true}`. Everything else —
2347
+ * `attachments`, `envelope`, `dkim`, `raw`, `icalEvent`, … — is dropped, because a
2348
+ * process with queue-write access would otherwise inherit nodemailer's whole
2349
+ * capability surface (local file read, URL fetch, sender/DKIM override).
2350
+ */
2351
+ const STRICT_MAIL_FIELDS = [
2352
+ 'to',
2353
+ 'cc',
2354
+ 'bcc',
2355
+ 'replyTo',
2356
+ 'subject',
2357
+ 'text',
2358
+ 'html',
2359
+ 'headers',
2360
+ 'list',
2361
+ 'priority',
2362
+ 'encoding',
2363
+ 'textEncoding',
2364
+ 'inReplyTo',
2365
+ 'references',
2366
+ 'date',
2367
+ ];
2368
+
1978
2369
  const __withLease = (task, updateObj) => {
1979
2370
  if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
1980
2371
  return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
@@ -2093,7 +2484,7 @@ const buildRejectionErrorMap = (info) => {
2093
2484
  return map;
2094
2485
  };
2095
2486
 
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>';
2487
+ 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
2488
 
2098
2489
  /**
2099
2490
  * @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
@@ -2124,7 +2515,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2124
2515
  */
2125
2516
 
2126
2517
  /**
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
2518
+ * @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
2519
+ */
2520
+
2521
+ /**
2522
+ * @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
2523
  */
2129
2524
 
2130
2525
  /**
@@ -2152,7 +2547,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2152
2547
  */
2153
2548
 
2154
2549
  /**
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
2550
+ * @typedef {{ index: number, from: string | undefined }} MailTimeFromDetails
2551
+ */
2552
+
2553
+ /**
2554
+ * @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
2555
  */
2157
2556
 
2158
2557
  /** Class of MailTime */
@@ -2213,7 +2612,34 @@ class MailTime {
2213
2612
  this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
2214
2613
  this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
2215
2614
  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;
2615
+ this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && Number.isFinite(opts.sendingTimeout) && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
2616
+ if (this.sendingTimeout < MIN_SAFE_SENDING_TIMEOUT) {
2617
+ 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.`);
2618
+ }
2619
+
2620
+ // Claim renewal: MailTime has no way to know how long a transport will take, so a
2621
+ // long-but-healthy send used to lose its lock to a "recovery" worker. While a send
2622
+ // is in flight the claim is re-stamped every {renewClaim} ms — bounded by
2623
+ // {maxRenewals} so a genuinely wedged send is still recovered, just later.
2624
+ if (opts.renewClaim === false || opts.renewClaim === 0) {
2625
+ this.renewClaim = 0;
2626
+ } else if (typeof opts.renewClaim === 'number' && Number.isFinite(opts.renewClaim) && opts.renewClaim > 0) {
2627
+ this.renewClaim = Math.floor(opts.renewClaim);
2628
+ } else {
2629
+ this.renewClaim = Math.max(1000, Math.floor(this.sendingTimeout / 3));
2630
+ }
2631
+ this.maxRenewals = (typeof opts.maxRenewals === 'number' && Number.isFinite(opts.maxRenewals) && opts.maxRenewals >= 0) ? Math.floor(opts.maxRenewals) : DEFAULT_MAX_RENEWALS;
2632
+
2633
+ // Transport fail-over policy. Rotating on *any* error can resubmit a letter the
2634
+ // MX already accepted; a transport that knows better says so.
2635
+ this.shouldFailOver = (typeof opts.shouldFailOver === 'function') ? opts.shouldFailOver.bind(this) : null;
2636
+
2637
+ this.strictPayload = opts.strictPayload === true;
2638
+ this.allowedMailFields = new Set([
2639
+ ...STRICT_MAIL_FIELDS,
2640
+ ...(Array.isArray(opts.allowedMailFields) ? opts.allowedMailFields.filter((field) => typeof field === 'string') : []),
2641
+ ]);
2642
+
2217
2643
  this.__isDestroyed = false;
2218
2644
  this.__abortInFlight = false;
2219
2645
  this.__isPaused = false;
@@ -2272,6 +2698,8 @@ class MailTime {
2272
2698
  this.__debug(`INITIALIZING [mode: ${this.mode}]`);
2273
2699
  this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
2274
2700
  this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
2701
+ this.__debug(`INITIALIZING [renewClaim: ${this.renewClaim}]`);
2702
+ this.__debug(`INITIALIZING [strictPayload: ${this.strictPayload}]`);
2275
2703
 
2276
2704
  /** SERVER-SPECIFIC CHECKS AND CONFIG */
2277
2705
  if (this.type === 'server') {
@@ -2336,6 +2764,7 @@ class MailTime {
2336
2764
  };
2337
2765
  }
2338
2766
 
2767
+ /** @type {MailTimeScheduler | undefined} */
2339
2768
  this.scheduler = new josk.JoSk({
2340
2769
  debug: this.debug,
2341
2770
  ...this.josk,
@@ -2501,12 +2930,17 @@ class MailTime {
2501
2930
  * @returns {Promise<string>} uuid of the email
2502
2931
  * @throws {Error}
2503
2932
  */
2504
- async sendMail(opts = {}) {
2933
+ async sendMail(opts) {
2934
+ opts = (opts && typeof opts === 'object') ? opts : {};
2505
2935
  this.__debug('[sendMail]', opts);
2506
2936
  if (!opts.html && !opts.text) {
2507
2937
  throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
2508
2938
  }
2509
2939
 
2940
+ if (opts.raw !== void 0) {
2941
+ 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.');
2942
+ }
2943
+
2510
2944
  let sendAt = opts.sendAt;
2511
2945
  if (sendAt instanceof Date) {
2512
2946
  sendAt = +sendAt;
@@ -2635,13 +3069,16 @@ class MailTime {
2635
3069
  task.isSending = false;
2636
3070
 
2637
3071
  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);
3072
+ callHook('onError', this.onError, error, task, info);
2639
3073
  return;
2640
3074
  }
2641
3075
 
2642
3076
  let transportIndex = task.transport;
2643
3077
 
2644
- if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) {
3078
+ if (this.strategy === 'backup'
3079
+ && this.transports.length > 1
3080
+ && (task.tries % this.failsToNext) === 0
3081
+ && this.___mayFailOver(error, info, task)) {
2645
3082
  transportIndex = this.___nextHealthyTransport(transportIndex);
2646
3083
  }
2647
3084
 
@@ -2703,12 +3140,18 @@ class MailTime {
2703
3140
  * @internal
2704
3141
  * @memberOf MailTime
2705
3142
  * @name ___render
2706
- * @description Render Mustache-like placeholders
3143
+ * @description Render Mustache-like placeholders. `{{{key}}}` is always verbatim.
3144
+ * `{{key}}` is HTML-escaped in HTML contexts (`html`, `template`, `concatDelimiter`) —
3145
+ * that is what double-brace means everywhere else, and the previous strip-tags pass was
3146
+ * not a safety net: it needs a closing `>`, so an unterminated `<a href="…` survived
3147
+ * into the rendered body. Text bodies and subject headers are not HTML, so `{{key}}`
3148
+ * substitutes verbatim there.
2707
3149
  * @param {string} _string - Template with Mustache-like placeholders
2708
3150
  * @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
3151
+ * @param {boolean} [isHtml=true] - `false` for text/plain bodies and header values
2709
3152
  * @returns {string}
2710
3153
  */
2711
- ___render(_string, replacements) {
3154
+ ___render(_string, replacements, isHtml = true) {
2712
3155
  let string = _string;
2713
3156
  const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
2714
3157
  if (matchHTML) {
@@ -2725,7 +3168,8 @@ class MailTime {
2725
3168
  for (let i = 0; i < matchStr.length; i++) {
2726
3169
  const key = matchStr[i].slice(2, -2).trim();
2727
3170
  if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
2728
- string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
3171
+ const value = `${replacements[key]}`;
3172
+ string = string.replace(matchStr[i], isHtml ? escapeHtml(value) : value);
2729
3173
  }
2730
3174
  }
2731
3175
  }
@@ -2764,7 +3208,7 @@ class MailTime {
2764
3208
  const isMulti = mailOptionsList.length > 1;
2765
3209
 
2766
3210
  for (let i = 0; i < mailOptionsList.length; i++) {
2767
- const mailOption = { ...mailOptionsList[i] };
3211
+ const mailOption = this.___applyPayloadPolicy({ ...mailOptionsList[i] });
2768
3212
 
2769
3213
  if (mailOption.html) {
2770
3214
  const rendered = this.___render(mailOption.html, mailOption);
@@ -2777,7 +3221,7 @@ class MailTime {
2777
3221
  }
2778
3222
 
2779
3223
  if (mailOption.text) {
2780
- const rendered = this.___render(mailOption.text, mailOption);
3224
+ const rendered = this.___render(mailOption.text, mailOption, false);
2781
3225
  if (isMulti) {
2782
3226
  compiledOpts.text += '\r\n' + rendered;
2783
3227
  } else {
@@ -2795,11 +3239,14 @@ class MailTime {
2795
3239
 
2796
3240
  if (isMulti) {
2797
3241
  const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
2798
- compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
3242
+ compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length }, false);
2799
3243
  }
2800
3244
 
2801
3245
  if (!compiledOpts.from && this.from) {
2802
- compiledOpts.from = this.from(transport);
3246
+ compiledOpts.from = this.from(transport, {
3247
+ index: this.transports.indexOf(transport),
3248
+ from: MailTime.transportFrom(transport),
3249
+ });
2803
3250
  }
2804
3251
 
2805
3252
  const acceptedSet = collectAcceptedSet(task);
@@ -2809,9 +3256,84 @@ class MailTime {
2809
3256
  compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
2810
3257
  }
2811
3258
 
3259
+ if (this.strictPayload) {
3260
+ compiledOpts.disableFileAccess = true;
3261
+ compiledOpts.disableUrlAccess = true;
3262
+ }
3263
+
2812
3264
  return compiledOpts;
2813
3265
  }
2814
3266
 
3267
+ /**
3268
+ * @internal
3269
+ * @memberOf MailTime
3270
+ * @name ___applyPayloadPolicy
3271
+ * @description Narrow one queued mail option to what a task is allowed to set. `raw` is
3272
+ * always dropped: it bypasses composition (so `template`, `concatEmails` and the
3273
+ * `from()` callback silently stop applying) and historically bypassed nodemailer's
3274
+ * `disableFileAccess`/`disableUrlAccess` with it. Under `{strictPayload: true}` only
3275
+ * `allowedMailFields` survive.
3276
+ * @param {MailTimeMailOptions} mailOption - a single queued mail option object
3277
+ * @returns {MailTimeMailOptions}
3278
+ */
3279
+ ___applyPayloadPolicy(mailOption) {
3280
+ if (mailOption.raw !== void 0) {
3281
+ delete mailOption.raw;
3282
+ logError('[___compileMailOpts] dropped {raw} from a queued task — `raw` bypasses composition and is never sent by MailTime');
3283
+ }
3284
+
3285
+ if (!this.strictPayload) {
3286
+ return mailOption;
3287
+ }
3288
+
3289
+ const allowed = {};
3290
+ for (const key of Object.keys(mailOption)) {
3291
+ if (this.allowedMailFields.has(key)) {
3292
+ allowed[key] = mailOption[key];
3293
+ } else {
3294
+ this.__debug('[___applyPayloadPolicy] dropped non-allowlisted field', key);
3295
+ }
3296
+ }
3297
+ return allowed;
3298
+ }
3299
+
3300
+ /**
3301
+ * @static
3302
+ * @memberOf MailTime
3303
+ * @name transportFrom
3304
+ * @description Best-effort sender address for a transport. `nodemailer.createTransport()`
3305
+ * only exposes `.options` for *plain-object* configs — for a class-instance transporter
3306
+ * (anything with its own `.send()`) nodemailer's internal `Mail.options` is always `{}`,
3307
+ * so `transport.options.from` silently reads `undefined`. This walks the places the
3308
+ * address can actually live.
3309
+ * @param {MailTimeTransport} transport
3310
+ * @returns {string | undefined}
3311
+ */
3312
+ static transportFrom(transport) {
3313
+ const candidates = [
3314
+ transport?.options?.from,
3315
+ transport?.transporter?.options?.from,
3316
+ transport?._defaults?.from,
3317
+ transport?._options?.from,
3318
+ ];
3319
+
3320
+ for (const candidate of candidates) {
3321
+ // nodemailer accepts `from` as a string or as `{ name, address }`. Resolve the
3322
+ // first candidate that yields a usable address — not the first truthy slot, so a
3323
+ // malformed `{ name }` object cannot mask a real address further down the chain.
3324
+ const address = (typeof candidate === 'string')
3325
+ ? candidate
3326
+ : (candidate && typeof candidate === 'object' && typeof candidate.address === 'string')
3327
+ ? candidate.address
3328
+ : '';
3329
+ if (address.trim()) {
3330
+ return address;
3331
+ }
3332
+ }
3333
+
3334
+ return void 0;
3335
+ }
3336
+
2815
3337
  /**
2816
3338
  * @internal
2817
3339
  * @memberOf MailTime
@@ -2876,6 +3398,124 @@ class MailTime {
2876
3398
  }
2877
3399
  }
2878
3400
 
3401
+ /**
3402
+ * @internal
3403
+ * @memberOf MailTime
3404
+ * @name ___mayFailOver
3405
+ * @description Whether this failure may be retried on the *next* transport. Rotating on
3406
+ * any error can resubmit a letter the receiving MTA already accepted (or may have
3407
+ * accepted — a socket that died during `DATA` is indistinguishable from one that died
3408
+ * before it). A transport signals "do not fail over" with `error.mayFailOver = false`;
3409
+ * the `shouldFailOver(error, info, task)` option overrides both.
3410
+ * @param {unknown} error - error returned by the transport
3411
+ * @param {object|undefined} info - info object returned by the transport
3412
+ * @param {MailTimeTask} task - email's task object from Storage
3413
+ * @returns {boolean}
3414
+ */
3415
+ ___mayFailOver(error, info, task) {
3416
+ if (this.shouldFailOver) {
3417
+ try {
3418
+ return this.shouldFailOver(error, info, task) !== false;
3419
+ } catch (policyError) {
3420
+ logError('[shouldFailOver] callback failed; keeping the current transport', policyError);
3421
+ return false;
3422
+ }
3423
+ }
3424
+ return !(error && typeof error === 'object' && error.mayFailOver === false);
3425
+ }
3426
+
3427
+ /**
3428
+ * @internal
3429
+ * @memberOf MailTime
3430
+ * @name ___startClaimRenewal
3431
+ * @description Hold a claim alive while its SMTP roundtrip runs. MailTime 4.x stamped
3432
+ * `sendingAt` once at claim time, so any send slower than `sendingTimeout` lost its lock
3433
+ * to a "recovery" worker and the letter went out twice. The renewal is a lease-guarded
3434
+ * update — it succeeds only while *this* worker still owns the row — and is bounded by
3435
+ * `maxRenewals` so a genuinely wedged send is still recovered.
3436
+ * @param {MailTimeTask} task - claimed task, with `isSending`/`sendingAt`/`tries` set
3437
+ * @returns {{ stop: () => Promise<void> }}
3438
+ */
3439
+ ___startClaimRenewal(task) {
3440
+ if (!this.renewClaim || !this.maxRenewals || this.type !== 'server') {
3441
+ return { stop: async () => {} };
3442
+ }
3443
+
3444
+ let stopped = false;
3445
+ let renewals = 0;
3446
+ let timer = null;
3447
+ let activeRenewal = null;
3448
+ const halt = () => {
3449
+ if (stopped) {
3450
+ return;
3451
+ }
3452
+ stopped = true;
3453
+ if (timer) {
3454
+ clearInterval(timer);
3455
+ timer = null;
3456
+ }
3457
+ };
3458
+ const stop = async () => {
3459
+ halt();
3460
+ if (activeRenewal) {
3461
+ await activeRenewal;
3462
+ }
3463
+ };
3464
+
3465
+ const renew = async () => {
3466
+ if (stopped || this.__isDestroyed || this.__abortInFlight) {
3467
+ halt();
3468
+ return;
3469
+ }
3470
+
3471
+ if (renewals >= this.maxRenewals) {
3472
+ this.__debug('[private renewClaim] renewal budget exhausted; letting the claim go stale', task.uuid);
3473
+ halt();
3474
+ return;
3475
+ }
3476
+ renewals += 1;
3477
+
3478
+ // Strictly greater than the current stamp: storages that report "modified" only on
3479
+ // an actual value change would otherwise read a same-millisecond renewal as a no-op.
3480
+ const renewedAt = Math.max(Date.now(), (typeof task.sendingAt === 'number' ? task.sendingAt : 0) + 1);
3481
+ let renewed = false;
3482
+ try {
3483
+ renewed = await this.queue.update(task, __withLease(task, {
3484
+ isSending: true,
3485
+ sendingAt: renewedAt,
3486
+ }));
3487
+ } catch (renewError) {
3488
+ logError('[private renewClaim] storage error during claim renewal', renewError);
3489
+ halt();
3490
+ return;
3491
+ }
3492
+
3493
+ if (!renewed) {
3494
+ this.__debug('[private renewClaim] lease lost; stopping renewal', task.uuid);
3495
+ halt();
3496
+ return;
3497
+ }
3498
+
3499
+ task.sendingAt = renewedAt;
3500
+ this.__debug(`[private renewClaim] renewed ${renewals}/${this.maxRenewals}`, task.uuid);
3501
+ };
3502
+
3503
+ timer = setInterval(() => {
3504
+ if (activeRenewal) {
3505
+ return;
3506
+ }
3507
+ activeRenewal = renew().finally(() => {
3508
+ activeRenewal = null;
3509
+ });
3510
+ }, this.renewClaim);
3511
+
3512
+ if (typeof timer.unref === 'function') {
3513
+ timer.unref();
3514
+ }
3515
+
3516
+ return { stop };
3517
+ }
3518
+
2879
3519
  /**
2880
3520
  * @async
2881
3521
  * @internal
@@ -2956,131 +3596,142 @@ class MailTime {
2956
3596
  const transport = this.transports[transportIndex];
2957
3597
 
2958
3598
  const compiledOpts = this.___compileMailOpts(transport, task);
3599
+ const renewal = this.___startClaimRenewal(task);
2959
3600
 
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);
3601
+ try {
3602
+ await new Promise((resolve) => {
3603
+ transport.sendMail(compiledOpts, async (error, info) => {
3604
+ // The roundtrip has settled — stop extending the lease before any completion
3605
+ // write, so the guard below sees the last `sendingAt` this worker persisted.
3606
+ await renewal.stop();
3607
+ if (this.__abortInFlight) {
3608
+ this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
3609
+ resolve();
2971
3610
  return;
2972
3611
  }
3612
+ this.__debug('[private send] [sending]', { error, info });
3613
+ try {
3614
+ if (error) {
3615
+ await this.___handleError(task, error, info);
3616
+ return;
3617
+ }
2973
3618
 
2974
- const acceptedAddrs = Array.isArray(info?.accepted)
2975
- ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
2976
- : [];
3619
+ const acceptedAddrs = Array.isArray(info?.accepted)
3620
+ ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
3621
+ : [];
2977
3622
 
2978
- if (acceptedAddrs.length === 0) {
2979
- await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
2980
- return;
2981
- }
3623
+ if (acceptedAddrs.length === 0) {
3624
+ await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
3625
+ return;
3626
+ }
2982
3627
 
2983
- this.___trackAcceptedRecipients(task, acceptedAddrs);
3628
+ this.___trackAcceptedRecipients(task, acceptedAddrs);
2984
3629
 
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;
3630
+ const allRecipients = collectAllRecipients(task);
3631
+ const allAccepted = collectAcceptedSet(task);
3632
+ let isFullyDelivered = true;
3633
+ for (const addr of allRecipients) {
3634
+ if (!allAccepted.has(addr)) {
3635
+ isFullyDelivered = false;
3636
+ break;
3637
+ }
2992
3638
  }
2993
- }
2994
-
2995
- if (isFullyDelivered) {
2996
- this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
2997
3639
 
2998
- const leaseRemove = __leaseRemoveOpts(task);
2999
- const leaseUpdate = __withLease(task, {
3000
- isSent: true,
3001
- isSending: false,
3002
- sendingAt: 0,
3003
- mailOptions: task.mailOptions,
3004
- });
3640
+ if (isFullyDelivered) {
3641
+ this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
3642
+
3643
+ const leaseRemove = __leaseRemoveOpts(task);
3644
+ const leaseUpdate = __withLease(task, {
3645
+ isSent: true,
3646
+ isSending: false,
3647
+ sendingAt: 0,
3648
+ mailOptions: task.mailOptions,
3649
+ });
3650
+
3651
+ let completed = false;
3652
+ if (!this.keepHistory) {
3653
+ completed = await this.queue.remove(task, leaseRemove);
3654
+ } else {
3655
+ completed = await this.queue.update(task, leaseUpdate);
3656
+ }
3005
3657
 
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
- }
3658
+ if (!completed) {
3659
+ this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
3660
+ return;
3661
+ }
3012
3662
 
3013
- if (!completed) {
3014
- this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
3663
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
3664
+ task.isSent = true;
3665
+ task.isSending = false;
3666
+ task.sendingAt = 0;
3667
+ callHook('onSent', this.onSent, task, info);
3015
3668
  return;
3016
3669
  }
3017
3670
 
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);
3671
+ if (task.tries >= this.maxTries) {
3672
+ this.___finalizeRejected(task, info);
3673
+
3674
+ const leaseRemove = __leaseRemoveOpts(task);
3675
+ const leaseUpdate = __withLease(task, {
3676
+ isSent: false,
3677
+ isFailed: true,
3678
+ isSending: false,
3679
+ sendingAt: 0,
3680
+ mailOptions: task.mailOptions,
3681
+ });
3682
+
3683
+ let finalized = false;
3684
+ if (!this.keepHistory) {
3685
+ finalized = await this.queue.remove(task, leaseRemove);
3686
+ } else {
3687
+ finalized = await this.queue.update(task, leaseUpdate);
3688
+ }
3028
3689
 
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
- });
3690
+ if (!finalized) {
3691
+ this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
3692
+ return;
3693
+ }
3037
3694
 
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
- }
3695
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
3696
+ task.isSent = false;
3697
+ task.isFailed = true;
3698
+ task.isSending = false;
3044
3699
 
3045
- if (!finalized) {
3046
- this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
3700
+ const rejectedAddrs = [];
3701
+ for (const mo of task.mailOptions) {
3702
+ for (const r of (mo.rejected || [])) {
3703
+ rejectedAddrs.push(r.address);
3704
+ }
3705
+ }
3706
+ const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
3707
+ this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
3708
+ callHook('onError', this.onError, partialError, task, info);
3047
3709
  return;
3048
3710
  }
3049
3711
 
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
- }
3712
+ const nextSendAt = Date.now() + this.retryDelay;
3713
+ const released = await this.queue.update(task, __withLease(task, {
3714
+ isSending: false,
3715
+ sendingAt: 0,
3716
+ sendAt: nextSendAt,
3717
+ mailOptions: task.mailOptions,
3718
+ }));
3719
+ if (!released) {
3720
+ this.__debug('[private send] lease lost before partial retry release', task.uuid);
3721
+ return;
3060
3722
  }
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;
3723
+ this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
3724
+ } catch (completionError) {
3725
+ logError('[private send] completion error after transport callback', completionError);
3726
+ } finally {
3727
+ resolve();
3065
3728
  }
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;
3077
- }
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
- }
3729
+ });
3082
3730
  });
3083
- });
3731
+ } finally {
3732
+ // A transport that throws synchronously never reaches the callback above.
3733
+ await renewal.stop();
3734
+ }
3084
3735
  } catch (e) {
3085
3736
  if (this.__abortInFlight) {
3086
3737
  this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
@@ -3167,7 +3818,7 @@ class MailTime {
3167
3818
  }
3168
3819
  this.__unhealthyTransports.add(r.index);
3169
3820
  logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
3170
- this.onError(r.error, null, { transportIndex: r.index, phase: 'verify' });
3821
+ callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
3171
3822
  }
3172
3823
 
3173
3824
  if (this.__unhealthyTransports.size === this.transports.length) {