mail-time 5.1.0 → 5.2.1

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
@@ -371,6 +371,7 @@ class MongoQueue {
371
371
  */
372
372
  constructor (opts) {
373
373
  this.name = 'mongo-queue';
374
+ this.supportsRecipientPolicies = true;
374
375
  if (!opts || typeof opts !== 'object') {
375
376
  throw new TypeError('[mail-time] Configuration object must be passed into MongoQueue constructor');
376
377
  }
@@ -391,8 +392,8 @@ class MongoQueue {
391
392
  this.collection = this.db.collection(`__mailTimeQueue__${prefix}`);
392
393
  this.__readyPromise = Promise.all([
393
394
  ensureIndex(this.collection, { uuid: 1 }, { background: false }),
394
- ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, to: 1, sendAt: 1 }, { background: false }),
395
- ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, isSending: 1, sendingAt: 1, sendAt: 1, tries: 1 }, { background: false }),
395
+ ensureIndex(this.collection, { isSettled: 1, isSent: 1, isFailed: 1, isCancelled: 1, to: 1, sendAt: 1 }, { name: 'mailtime_policy_pending_to_v1', background: false }),
396
+ ensureIndex(this.collection, { isSettled: 1, isSent: 1, isFailed: 1, isCancelled: 1, isSending: 1, sendingAt: 1, sendAt: 1, tries: 1 }, { name: 'mailtime_policy_due_v1', background: false }),
396
397
  ]).then(() => void 0);
397
398
  }
398
399
 
@@ -491,12 +492,13 @@ class MongoQueue {
491
492
  sendAt: {
492
493
  $lte: now,
493
494
  },
494
- tries: {
495
- $lt: this.mailTimeInstance.maxTries,
496
- },
497
- $or: [
498
- { isSending: { $ne: true } },
499
- { sendingAt: { $lte: now - sendingTimeout } },
495
+ isSettled: { $ne: true },
496
+ $and: [
497
+ { $or: [{ isSending: { $ne: true } }, { sendingAt: { $lte: now - sendingTimeout } }] },
498
+ { $or: [
499
+ { tries: { $lt: this.mailTimeInstance.maxTries } },
500
+ { recipientResults: { $type: 'array' }, isSending: true, sendingAt: { $lte: now - sendingTimeout } },
501
+ ] },
500
502
  ],
501
503
  }, {
502
504
  projection: {
@@ -512,6 +514,8 @@ class MongoQueue {
512
514
  sendingAt: 1,
513
515
  mailOptions: 1,
514
516
  concatSubject: 1,
517
+ isSettled: 1,
518
+ recipientResults: 1,
515
519
  },
516
520
  });
517
521
 
@@ -520,7 +524,7 @@ class MongoQueue {
520
524
  }
521
525
 
522
526
  try {
523
- while (await cursor.hasNext()) {
527
+ while (!this.mailTimeInstance.___isStopped && await cursor.hasNext()) {
524
528
  await this.mailTimeInstance.___dispatch(await cursor.next());
525
529
  }
526
530
  } finally {
@@ -553,6 +557,8 @@ class MongoQueue {
553
557
  isFailed: false,
554
558
  isCancelled: false,
555
559
  isSending: { $ne: true },
560
+ isSettled: { $ne: true },
561
+ recipientResults: { $exists: false },
556
562
  tries: { $lt: this.mailTimeInstance.maxTries },
557
563
  sendAt: {
558
564
  $lte: sendAt,
@@ -566,6 +572,8 @@ class MongoQueue {
566
572
  isSent: 1,
567
573
  isFailed: 1,
568
574
  isCancelled: 1,
575
+ isSettled: 1,
576
+ recipientResults: 1,
569
577
  mailOptions: 1,
570
578
  },
571
579
  });
@@ -613,20 +621,20 @@ class MongoQueue {
613
621
  uuid: 1,
614
622
  isSent: 1,
615
623
  isCancelled: 1,
624
+ isSettled: 1,
616
625
  },
617
626
  });
618
627
 
619
- if (!task || task.isSent === true || task.isCancelled === true) {
628
+ if (!task || task.isSent === true || task.isCancelled === true || task.isSettled === true) {
620
629
  return false;
621
630
  }
622
-
631
+ const query = { _id: task._id, isSent: false, isCancelled: false, isSettled: { $ne: true },
632
+ $or: [{ recipientResults: { $exists: false } }, { isFailed: false }] };
623
633
  if (!this.mailTimeInstance.keepHistory) {
624
- return await this.remove(task);
634
+ return ((await this.collection.deleteOne(query))?.deletedCount || 0) >= 1;
625
635
  }
626
-
627
- return await this.update(task, {
628
- isCancelled: true,
629
- });
636
+ const result = await this.collection.updateOne(query, { $set: { isCancelled: true } });
637
+ return (result?.matchedCount || result?.modifiedCount || 0) >= 1;
630
638
  }
631
639
 
632
640
  /**
@@ -647,6 +655,8 @@ class MongoQueue {
647
655
 
648
656
  const query = { _id: task._id };
649
657
  if (isSendLeaseRemove(opts)) {
658
+ query.isSent = false;
659
+ query.isSettled = { $ne: true };
650
660
  query.tries = opts.leaseTries;
651
661
  query.isSending = true;
652
662
  query.sendingAt = opts.leaseSendingAt;
@@ -673,6 +683,8 @@ class MongoQueue {
673
683
  return false;
674
684
  }
675
685
  this.__ensurePrefix();
686
+ if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled'))
687
+ && !isSendClaimUpdate(updateObj) && !isSendLeaseGuardedUpdate(updateObj)) return false;
676
688
 
677
689
  if (isAppendMailOptionUpdate(updateObj)) {
678
690
  const res = await this.collection.updateOne({
@@ -681,6 +693,8 @@ class MongoQueue {
681
693
  isFailed: false,
682
694
  isCancelled: false,
683
695
  isSending: { $ne: true },
696
+ isSettled: { $ne: true },
697
+ recipientResults: { $exists: false },
684
698
  }, {
685
699
  $push: {
686
700
  mailOptions: updateObj.appendMailOption,
@@ -699,12 +713,15 @@ class MongoQueue {
699
713
  query.isSent = false;
700
714
  query.isFailed = false;
701
715
  query.isCancelled = false;
716
+ query.isSettled = { $ne: true };
702
717
  query.tries = task.tries;
703
718
  query.$or = [
704
719
  { isSending: { $ne: true } },
705
720
  { sendingAt: { $lte: now - sendingTimeout } },
706
721
  ];
707
722
  } else if (isSendLeaseGuardedUpdate(updateObj)) {
723
+ query.isSent = false;
724
+ query.isSettled = { $ne: true };
708
725
  query.tries = updateObj.leaseTries;
709
726
  query.isSending = true;
710
727
  query.sendingAt = updateObj.leaseSendingAt;
@@ -715,7 +732,7 @@ class MongoQueue {
715
732
  const res = await this.collection.updateOne(query, {
716
733
  $set: stripInternalUpdateMeta(updateObj),
717
734
  });
718
- if (isSendClaimUpdate(updateObj)) {
735
+ if (isSendClaimUpdate(updateObj) || isSendLeaseGuardedUpdate(updateObj)) {
719
736
  return (res?.modifiedCount || res?.matchedCount || 0) >= 1;
720
737
  }
721
738
  return (res?.modifiedCount || 0) >= 1;
@@ -728,9 +745,12 @@ class MongoQueue {
728
745
  * @property {(key: string) => Promise<string|null>} get
729
746
  * @property {(key: string, value: string, options?: object) => Promise<unknown>} set
730
747
  * @property {(key: string|string[]) => Promise<number>} del
731
- * @property {() => Promise<string>} ping
748
+ * @property {() => Promise<string>} [ping]
749
+ * @property {() => unknown} [getRandomNode]
750
+ * @property {(...args: any[]) => any} [nodeClient]
751
+ * @property {(firstKey: string, isReadonly: boolean, args: string[]) => Promise<unknown>} [sendCommand]
732
752
  * @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
733
- * @property {(key: string, field: string) => Promise<string|null>} [hGet]
753
+ * @property {(key: string, field: string) => Promise<string|null|undefined>} [hGet]
734
754
  * @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
735
755
  * @property {(sha: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [evalSha]
736
756
  * @property {(script: string) => Promise<string>} [scriptLoad]
@@ -750,6 +770,7 @@ const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
750
770
  const DEFAULT_PREFIX$1 = 'default';
751
771
  const VALID_PREFIX = /^[A-Za-z0-9_\-:.]+$/;
752
772
  const TAGGED_ITERATE_LIMIT = 100;
773
+ const clientTransactions = new WeakMap();
753
774
 
754
775
  const PUSH_TAGGED_TASK_SCRIPT = `
755
776
  redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
@@ -782,17 +803,19 @@ const ITERATE_TAGGED_TASKS_SCRIPT = `
782
803
  redis.call('ZREM', KEYS[2], uuid)
783
804
  else
784
805
  local task = cjson.decode(payload)
785
- if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) >= maxTries then
806
+ if task.isSent or task.isFailed or task.isCancelled or task.isSettled then
807
+ redis.call('ZREM', KEYS[2], uuid)
808
+ elseif tonumber(task.tries or 0) >= maxTries and not (type(task.recipientResults) == 'table' and task.isSending) then
786
809
  redis.call('ZREM', KEYS[2], uuid)
787
810
  elseif task.isSending then
788
811
  local eligibleAt = tonumber(task.sendingAt or 0) + sendingTimeout
789
812
  if eligibleAt > now then
790
813
  redis.call('ZADD', KEYS[2], eligibleAt, uuid)
791
814
  else
792
- table.insert(tasks, task)
815
+ table.insert(tasks, payload)
793
816
  end
794
817
  elseif tonumber(task.sendAt or 0) <= now then
795
- table.insert(tasks, task)
818
+ table.insert(tasks, payload)
796
819
  else
797
820
  redis.call('ZADD', KEYS[2], tonumber(task.sendAt), uuid)
798
821
  end
@@ -819,28 +842,33 @@ const UPDATE_TAGGED_TASK_SCRIPT = `
819
842
  local sendingTimeout = tonumber(ARGV[5])
820
843
  local expectedTries = tonumber(ARGV[6])
821
844
  local leaseSendingAt = tonumber(ARGV[7])
845
+ if payload ~= ARGV[8] then return -1 end
822
846
 
823
847
  if mode == 'claim' then
824
- if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) ~= expectedTries then
848
+ if task.isSent or task.isFailed or task.isCancelled or task.isSettled or tonumber(task.tries or 0) ~= expectedTries then
825
849
  return 0
826
850
  end
827
851
  if task.isSending and tonumber(task.sendingAt or 0) > now - sendingTimeout then
828
852
  return 0
829
853
  end
830
854
  elseif mode == 'lease' then
831
- if task.isCancelled or task.isFailed or not task.isSending
855
+ if task.isSent or task.isSettled or task.isCancelled or task.isFailed or not task.isSending
832
856
  or tonumber(task.tries or 0) ~= expectedTries
833
857
  or tonumber(task.sendingAt or 0) ~= leaseSendingAt then
834
858
  return 0
835
859
  end
836
860
  end
837
861
 
862
+ if mode == 'cancel' and (task.isSent or task.isSettled or task.isCancelled
863
+ or (type(task.recipientResults) == 'table' and task.isFailed)) then
864
+ return 0
865
+ end
838
866
  for key, value in pairs(update) do
839
867
  task[key] = value
840
868
  end
841
- redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
869
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[9])
842
870
 
843
- if task.isSent or task.isFailed or task.isCancelled then
871
+ if task.isSent or task.isFailed or task.isCancelled or task.isSettled then
844
872
  redis.call('ZREM', KEYS[2], ARGV[1])
845
873
  if #KEYS > 3 then
846
874
  local concatUuid = redis.call('GET', KEYS[3])
@@ -865,12 +893,11 @@ const APPEND_TAGGED_MAIL_OPTION_SCRIPT = `
865
893
  return 0
866
894
  end
867
895
  local task = cjson.decode(payload)
868
- if task.isSending or task.isSent or task.isFailed or task.isCancelled then
896
+ if task.isSending or task.isSent or task.isFailed or task.isCancelled or task.isSettled or type(task.recipientResults) == 'table' then
869
897
  return 0
870
898
  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))
899
+ if payload ~= ARGV[3] then return -1 end
900
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[4])
874
901
  return 1
875
902
  `;
876
903
 
@@ -881,7 +908,9 @@ const REMOVE_TAGGED_TASK_SCRIPT = `
881
908
  return 0
882
909
  end
883
910
  local task = cjson.decode(payload)
884
- if ARGV[2] == 'lease' and (task.isCancelled or task.isFailed or not task.isSending
911
+ if ARGV[2] == 'cancel' and (task.isSent or task.isSettled or task.isCancelled
912
+ or (type(task.recipientResults) == 'table' and task.isFailed)) then return 0 end
913
+ if ARGV[2] == 'lease' and (task.isSent or task.isSettled or task.isCancelled or task.isFailed or not task.isSending
885
914
  or tonumber(task.tries or 0) ~= tonumber(ARGV[3])
886
915
  or tonumber(task.sendingAt or 0) ~= tonumber(ARGV[4])) then
887
916
  return 0
@@ -900,6 +929,18 @@ const REMOVE_TAGGED_TASK_SCRIPT = `
900
929
  return 1
901
930
  `;
902
931
 
932
+ const normalizePolicyArrays = (task) => {
933
+ if (task.recipientResults && !Array.isArray(task.recipientResults) && Object.keys(task.recipientResults).length === 0) task.recipientResults = [];
934
+ if (Array.isArray(task.recipientResults)) {
935
+ for (const result of task.recipientResults) {
936
+ for (const field of ['sources', 'reasons']) {
937
+ if (result[field] && !Array.isArray(result[field]) && Object.keys(result[field]).length === 0) result[field] = [];
938
+ }
939
+ }
940
+ }
941
+ return task;
942
+ };
943
+
903
944
  const sha1Hex = (string) => crypto.createHash('sha1').update(string).digest('hex');
904
945
 
905
946
  const isNoScriptError = (error) => {
@@ -912,6 +953,8 @@ const canReleaseLease = (currentTask, updateObj) => {
912
953
  && currentTask.isSending === true
913
954
  && (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) === updateObj.leaseSendingAt
914
955
  && currentTask.isCancelled !== true
956
+ && currentTask.isSent !== true
957
+ && currentTask.isSettled !== true
915
958
  && currentTask.isFailed !== true;
916
959
  };
917
960
 
@@ -919,7 +962,7 @@ const canClaimTask = (currentTask, task, now, sendingTimeout) => {
919
962
  if (!currentTask) {
920
963
  return false;
921
964
  }
922
- if (currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
965
+ if (currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true || currentTask.isSettled === true) {
923
966
  return false;
924
967
  }
925
968
  if (currentTask.tries !== task.tries) {
@@ -938,11 +981,11 @@ const isIterateCandidate = (candidate, now, sendingTimeout, maxTries) => {
938
981
  if (!candidate || typeof candidate !== 'object') {
939
982
  return false;
940
983
  }
941
- if (candidate.isSent === true || candidate.isFailed === true || candidate.isCancelled === true) {
984
+ if (candidate.isSent === true || candidate.isFailed === true || candidate.isCancelled === true || candidate.isSettled === true) {
942
985
  return false;
943
986
  }
944
987
  const tries = typeof candidate.tries === 'number' ? candidate.tries : 0;
945
- if (tries >= maxTries) {
988
+ if (tries >= maxTries && !(Array.isArray(candidate.recipientResults) && candidate.isSending === true)) {
946
989
  return false;
947
990
  }
948
991
  if (candidate.isSending === true) {
@@ -970,6 +1013,7 @@ class RedisQueue {
970
1013
  */
971
1014
  constructor (opts) {
972
1015
  this.name = 'redis-queue';
1016
+ this.supportsRecipientPolicies = true;
973
1017
  if (!opts || typeof opts !== 'object') {
974
1018
  throw new TypeError('[mail-time] Configuration object must be passed into RedisQueue constructor');
975
1019
  }
@@ -1032,6 +1076,17 @@ class RedisQueue {
1032
1076
  throw new Error(`[mail-time] [RedisQueue] unknown script "${scriptKey}"`);
1033
1077
  }
1034
1078
 
1079
+ if (this.useHashTags && typeof this.client.nodeClient === 'function' && typeof this.client.sendCommand === 'function') {
1080
+ const key = options.keys[0];
1081
+ const args = [`${options.keys.length}`, ...options.keys, ...options.arguments];
1082
+ try {
1083
+ return await this.client.sendCommand(key, false, ['EVALSHA', sha, ...args]);
1084
+ } catch (error) {
1085
+ if (!isNoScriptError(error)) throw error;
1086
+ }
1087
+ return await this.client.sendCommand(key, false, ['EVAL', source, ...args]);
1088
+ }
1089
+
1035
1090
  if (this.__loadedShas.has(sha) && typeof this.client.evalSha === 'function') {
1036
1091
  try {
1037
1092
  return await this.client.evalSha(sha, options);
@@ -1099,7 +1154,9 @@ class RedisQueue {
1099
1154
  }
1100
1155
 
1101
1156
  try {
1102
- const ping = await this.client.ping();
1157
+ const pingClient = typeof this.client.ping === 'function' ? this.client
1158
+ : await this.client.nodeClient(this.client.getRandomNode());
1159
+ const ping = await pingClient.ping();
1103
1160
  if (ping === 'PONG') {
1104
1161
  return {
1105
1162
  status: 'OK',
@@ -1156,7 +1213,11 @@ class RedisQueue {
1156
1213
  if (!Array.isArray(candidates)) {
1157
1214
  return;
1158
1215
  }
1159
- for (const candidate of candidates) {
1216
+ for (const value of candidates) {
1217
+ if (this.mailTimeInstance.___isStopped) {
1218
+ break;
1219
+ }
1220
+ const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
1160
1221
  if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
1161
1222
  await this.mailTimeInstance.___dispatch(candidate);
1162
1223
  }
@@ -1176,6 +1237,9 @@ class RedisQueue {
1176
1237
  for await (const cursorValue of cursor) {
1177
1238
  const sendatKeys = Array.isArray(cursorValue) ? cursorValue : [cursorValue];
1178
1239
  for (const sendatKey of sendatKeys) {
1240
+ if (this.mailTimeInstance.___isStopped) {
1241
+ break outer;
1242
+ }
1179
1243
  const raw = await this.client.get(sendatKey);
1180
1244
  if (raw === null || parseInt(raw, 10) > now) {
1181
1245
  continue;
@@ -1233,7 +1297,7 @@ class RedisQueue {
1233
1297
  }
1234
1298
 
1235
1299
  const task = JSON.parse(taskJSON);
1236
- if (!task || task.isSent === true || task.isCancelled === true || task.isFailed === true || task.sendAt > sendAt || task.isSending === true || task.tries >= this.mailTimeInstance.maxTries) {
1300
+ if (!task || task.isSettled === true || task.recipientResults != null || task.isSent === true || task.isCancelled === true || task.isFailed === true || task.sendAt > sendAt || task.isSending === true || task.tries >= this.mailTimeInstance.maxTries) {
1237
1301
  return null;
1238
1302
  }
1239
1303
 
@@ -1306,35 +1370,59 @@ class RedisQueue {
1306
1370
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
1307
1371
  */
1308
1372
  async cancel(uuid) {
1373
+ return await this.__serialize(() => this.__cancel(uuid));
1374
+ }
1375
+
1376
+ /** @internal */
1377
+ async __serialize(operation) {
1378
+ if (this.useHashTags) return await operation();
1379
+ const previous = clientTransactions.get(this.client) || Promise.resolve();
1380
+ const pending = previous.then(operation);
1381
+ const settled = pending.catch(() => void 0);
1382
+ clientTransactions.set(this.client, settled);
1383
+ try { return await pending; }
1384
+ finally { if (clientTransactions.get(this.client) === settled) clientTransactions.delete(this.client); }
1385
+ }
1386
+
1387
+ /** @internal */
1388
+ async __cancel(uuid) {
1309
1389
  this.__debug('[cancel]', uuid);
1310
1390
  if (typeof uuid !== 'string') {
1311
1391
  return false;
1312
1392
  }
1313
1393
 
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'));
1322
- if (!taskJSON) {
1323
- return false;
1324
- }
1325
-
1326
- const task = JSON.parse(taskJSON);
1327
- if (!task || task.isSent === true || task.isCancelled === true) {
1394
+ this.__ensurePrefix();
1395
+ const atomic = typeof this.client.watch === 'function' && typeof this.client.multi === 'function';
1396
+ const letterKey = this.__getKey(uuid);
1397
+ try {
1398
+ if (!this.useHashTags && atomic) await this.client.watch(letterKey);
1399
+ const payload = this.useHashTags ? await this.client.hGet(this.lettersKey, uuid) : await this.client.get(letterKey);
1400
+ const task = payload ? JSON.parse(payload) : null;
1401
+ if (!task || task.isSent || task.isCancelled || task.isSettled || (task.recipientResults != null && task.isFailed)) return false;
1402
+ if (this.useHashTags) {
1403
+ const keys = [this.lettersKey, this.scheduleKey];
1404
+ if (task.to) keys.push(this.__getTaggedConcatKey(task.to), this.concatKeysKey);
1405
+ const keep = this.mailTimeInstance.keepHistory;
1406
+ const args = keep
1407
+ ? [uuid, JSON.stringify({ isCancelled: true }), 'cancel', `${Date.now()}`, `${this.mailTimeInstance.sendingTimeout || 300000}`, '0', '0', payload, JSON.stringify({ ...task, isCancelled: true })]
1408
+ : [uuid, 'cancel', '0', '0'];
1409
+ return Number(await this.__runScript(keep ? 'update' : 'remove', { keys, arguments: args })) >= 1;
1410
+ }
1411
+ if (!atomic) {
1412
+ if (task.recipientResults != null) return false;
1413
+ return this.mailTimeInstance.keepHistory ? await this.__update(task, { isCancelled: true }) : await this.__remove(task);
1414
+ }
1415
+ const multi = this.client.multi();
1416
+ if (this.mailTimeInstance.keepHistory) multi.set(letterKey, JSON.stringify({ ...task, isCancelled: true }));
1417
+ else multi.del(letterKey);
1418
+ multi.del(this.__getKey(uuid, 'sendat'));
1419
+ return (await multi.exec()) !== null;
1420
+ } catch (error) {
1421
+ logError('[cancel] storage error', error);
1328
1422
  return false;
1423
+ } finally {
1424
+ if (!this.useHashTags && atomic) await this.client.unwatch?.();
1329
1425
  }
1330
-
1331
- if (!this.mailTimeInstance.keepHistory) {
1332
- return await this.remove(task);
1333
- }
1334
-
1335
- return await this.update(task, {
1336
- isCancelled: true,
1337
- });
1338
1426
  }
1339
1427
 
1340
1428
  /**
@@ -1347,6 +1435,11 @@ class RedisQueue {
1347
1435
  * @returns {Promise<boolean>} returns `true` if removed or `false` if not found
1348
1436
  */
1349
1437
  async remove(task, opts) {
1438
+ return await this.__serialize(() => this.__remove(task, opts));
1439
+ }
1440
+
1441
+ /** @internal */
1442
+ async __remove(task, opts) {
1350
1443
  this.__debug('[remove]', task?.uuid);
1351
1444
  if (!task || typeof task !== 'object' || typeof task.uuid !== 'string') {
1352
1445
  return false;
@@ -1393,6 +1486,8 @@ class RedisQueue {
1393
1486
  || currentTask.isSending !== true
1394
1487
  || (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) !== opts.leaseSendingAt
1395
1488
  || currentTask.isCancelled === true
1489
+ || currentTask.isSent === true
1490
+ || currentTask.isSettled === true
1396
1491
  || currentTask.isFailed === true) {
1397
1492
  await this.client.unwatch?.();
1398
1493
  return false;
@@ -1436,6 +1531,11 @@ class RedisQueue {
1436
1531
  * @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
1437
1532
  */
1438
1533
  async update(task, updateObj) {
1534
+ return await this.__serialize(() => this.__update(task, updateObj));
1535
+ }
1536
+
1537
+ /** @internal */
1538
+ async __update(task, updateObj) {
1439
1539
  this.__debug('[update]', task?.uuid);
1440
1540
  if (!task || typeof task !== 'object' || typeof task.uuid !== 'string' || !updateObj || typeof updateObj !== 'object') {
1441
1541
  return false;
@@ -1446,38 +1546,41 @@ class RedisQueue {
1446
1546
  const isClaim = isSendClaimUpdate(updateObj);
1447
1547
  const isAppend = isAppendMailOptionUpdate(updateObj);
1448
1548
  const isLeaseRelease = isSendLeaseGuardedUpdate(updateObj);
1549
+ if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled')) && !isClaim && !isLeaseRelease) return false;
1449
1550
  const now = isClaim && typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
1450
1551
  const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
1451
1552
 
1452
1553
  try {
1453
1554
  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);
1555
+ // JS serialization preserves empty arrays and scalar values that Lua cjson changes.
1556
+ // The script compares the exact payload as well as the claim/lease predicates.
1557
+ for (let attempt = 0; attempt < 3; attempt++) {
1558
+ const payload = await this.client.hGet(this.lettersKey, task.uuid);
1559
+ if (!payload) return false;
1560
+ const current = normalizePolicyArrays(JSON.parse(payload));
1561
+ const persist = stripInternalUpdateMeta(updateObj);
1562
+ const next = isAppend
1563
+ ? { ...current, mailOptions: [...(current.mailOptions || []), updateObj.appendMailOption] }
1564
+ : { ...current, ...persist };
1565
+ if (isAppend) {
1566
+ const result = Number(await this.__runScript('append', {
1567
+ keys: [this.lettersKey],
1568
+ arguments: [task.uuid, JSON.stringify(updateObj.appendMailOption), payload, JSON.stringify(next)],
1569
+ }));
1570
+ if (result === -1) continue;
1571
+ return result >= 1;
1572
+ }
1573
+ const keys = [this.lettersKey, this.scheduleKey];
1574
+ if (current.to) keys.push(this.__getTaggedConcatKey(current.to), this.concatKeysKey);
1575
+ const result = Number(await this.__runScript('update', {
1576
+ keys,
1577
+ arguments: [task.uuid, JSON.stringify(persist), isClaim ? 'claim' : (isLeaseRelease ? 'lease' : 'plain'),
1578
+ `${now}`, `${sendingTimeout}`, `${isClaim ? task.tries : (updateObj.leaseTries || 0)}`,
1579
+ `${updateObj.leaseSendingAt || 0}`, payload, JSON.stringify(next)],
1580
+ }));
1581
+ if (result !== -1) return result >= 1;
1467
1582
  }
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;
1583
+ return false;
1481
1584
  }
1482
1585
 
1483
1586
  if (isAppend) {
@@ -1491,7 +1594,7 @@ class RedisQueue {
1491
1594
  return false;
1492
1595
  }
1493
1596
  const currentTask = JSON.parse(taskJSON);
1494
- if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
1597
+ if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true || currentTask.isSettled === true || Array.isArray(currentTask.recipientResults)) {
1495
1598
  return false;
1496
1599
  }
1497
1600
  currentTask.mailOptions = [...(currentTask.mailOptions || []), updateObj.appendMailOption];
@@ -1506,7 +1609,7 @@ class RedisQueue {
1506
1609
  return false;
1507
1610
  }
1508
1611
  const currentTask = JSON.parse(taskJSON);
1509
- if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
1612
+ if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true || currentTask.isSettled === true || Array.isArray(currentTask.recipientResults)) {
1510
1613
  await this.client.unwatch?.();
1511
1614
  return false;
1512
1615
  }
@@ -1546,7 +1649,7 @@ class RedisQueue {
1546
1649
  const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
1547
1650
  const multi = this.client.multi();
1548
1651
  multi.set(letterKey, JSON.stringify(updatedTask));
1549
- if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
1652
+ if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true || updatedTask.isSettled === true) {
1550
1653
  multi.del(sendatKey);
1551
1654
  } else if (updatedTask.sendAt) {
1552
1655
  multi.set(sendatKey, `${+updatedTask.sendAt}`);
@@ -1565,7 +1668,7 @@ class RedisQueue {
1565
1668
  const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
1566
1669
  await this.client.set(letterKey, JSON.stringify(updatedTask));
1567
1670
 
1568
- if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
1671
+ if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true || updatedTask.isSettled === true) {
1569
1672
  await this.client.del(sendatKey);
1570
1673
  } else if (updatedTask.sendAt) {
1571
1674
  await this.client.set(sendatKey, `${+updatedTask.sendAt}`);
@@ -1643,6 +1746,8 @@ const fieldMap = {
1643
1746
  tries: 'tries',
1644
1747
  sendAt: 'send_at',
1645
1748
  isSent: 'is_sent',
1749
+ isSettled: 'is_settled',
1750
+ recipientResults: 'recipient_results',
1646
1751
  isCancelled: 'is_cancelled',
1647
1752
  isFailed: 'is_failed',
1648
1753
  isSending: 'is_sending',
@@ -1672,6 +1777,8 @@ const normalizeRow = (row) => {
1672
1777
  tries: parseInt(row.tries, 10),
1673
1778
  sendAt: parseInt(row.send_at, 10),
1674
1779
  isSent: row.is_sent,
1780
+ isSettled: row.is_settled === true,
1781
+ recipientResults: row.recipient_results == null ? void 0 : parseMailOptions(row.recipient_results),
1675
1782
  isCancelled: row.is_cancelled,
1676
1783
  isFailed: row.is_failed,
1677
1784
  isSending: row.is_sending === true,
@@ -1691,6 +1798,7 @@ class PostgresQueue {
1691
1798
  */
1692
1799
  constructor(opts) {
1693
1800
  this.name = 'postgres-queue';
1801
+ this.supportsRecipientPolicies = true;
1694
1802
  if (!opts || typeof opts !== 'object') {
1695
1803
  throw new TypeError('[mail-time] Configuration object must be passed into PostgresQueue constructor');
1696
1804
  }
@@ -1751,6 +1859,8 @@ class PostgresQueue {
1751
1859
  tries INTEGER NOT NULL DEFAULT 0,
1752
1860
  send_at BIGINT NOT NULL,
1753
1861
  is_sent BOOLEAN NOT NULL DEFAULT false,
1862
+ is_settled BOOLEAN NOT NULL DEFAULT false,
1863
+ recipient_results JSONB,
1754
1864
  is_cancelled BOOLEAN NOT NULL DEFAULT false,
1755
1865
  is_failed BOOLEAN NOT NULL DEFAULT false,
1756
1866
  is_sending BOOLEAN NOT NULL DEFAULT false,
@@ -1762,14 +1872,16 @@ class PostgresQueue {
1762
1872
  created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
1763
1873
  updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
1764
1874
  )`);
1875
+ await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS is_settled BOOLEAN NOT NULL DEFAULT false');
1876
+ await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS recipient_results JSONB');
1765
1877
  await this.client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
1766
1878
  ON mail_time_queue (prefix, uuid)`);
1767
1879
 
1768
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_due
1769
- ON mail_time_queue (prefix, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
1880
+ await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_due_v1
1881
+ ON mail_time_queue (prefix, is_settled, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
1770
1882
 
1771
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_pending_to
1772
- ON mail_time_queue (prefix, to_address, is_sent, is_failed, is_cancelled, send_at)`);
1883
+ await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_pending_to_v1
1884
+ ON mail_time_queue (prefix, to_address, is_settled, is_sent, is_failed, is_cancelled, send_at)`);
1773
1885
  } finally {
1774
1886
  await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1775
1887
  }
@@ -1844,19 +1956,23 @@ class PostgresQueue {
1844
1956
 
1845
1957
  try {
1846
1958
  const res = await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1847
- is_sending, sending_at, template, transport, concat_subject, mail_options
1959
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results
1848
1960
  FROM mail_time_queue
1849
1961
  WHERE prefix = $1
1850
1962
  AND is_sent = false
1851
1963
  AND is_failed = false
1852
1964
  AND is_cancelled = false
1965
+ AND is_settled = false
1853
1966
  AND send_at <= $2
1854
- AND tries < $3
1967
+ AND (tries < $3 OR (recipient_results IS NOT NULL AND is_sending = true AND sending_at <= $4))
1855
1968
  AND (is_sending = false OR sending_at <= $4)
1856
1969
  ORDER BY send_at ASC
1857
1970
  LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
1858
1971
 
1859
1972
  for (const row of res.rows || []) {
1973
+ if (this.mailTimeInstance.___isStopped) {
1974
+ break;
1975
+ }
1860
1976
  await this.mailTimeInstance.___dispatch(normalizeRow(row));
1861
1977
  }
1862
1978
  } catch (iterateError) {
@@ -1882,7 +1998,7 @@ class PostgresQueue {
1882
1998
  await this.ready();
1883
1999
 
1884
2000
  const res = await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1885
- is_sending, sending_at, template, transport, concat_subject, mail_options
2001
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results
1886
2002
  FROM mail_time_queue
1887
2003
  WHERE prefix = $1
1888
2004
  AND to_address = $2
@@ -1890,6 +2006,8 @@ class PostgresQueue {
1890
2006
  AND is_failed = false
1891
2007
  AND is_cancelled = false
1892
2008
  AND is_sending = false
2009
+ AND is_settled = false
2010
+ AND recipient_results IS NULL
1893
2011
  AND tries < $4
1894
2012
  AND send_at <= $3
1895
2013
  ORDER BY send_at DESC
@@ -1920,9 +2038,9 @@ class PostgresQueue {
1920
2038
 
1921
2039
  await this.client.query(`INSERT INTO mail_time_queue (
1922
2040
  prefix, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1923
- is_sending, sending_at, template, transport, concat_subject, mail_options, created_at, updated_at
2041
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results, created_at, updated_at
1924
2042
  )
1925
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
2043
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
1926
2044
  ON CONFLICT (prefix, uuid) DO UPDATE SET
1927
2045
  to_address = EXCLUDED.to_address,
1928
2046
  tries = EXCLUDED.tries,
@@ -1936,6 +2054,8 @@ class PostgresQueue {
1936
2054
  transport = EXCLUDED.transport,
1937
2055
  concat_subject = EXCLUDED.concat_subject,
1938
2056
  mail_options = EXCLUDED.mail_options,
2057
+ is_settled = EXCLUDED.is_settled,
2058
+ recipient_results = EXCLUDED.recipient_results,
1939
2059
  updated_at = CURRENT_TIMESTAMP`, [
1940
2060
  this.prefix,
1941
2061
  task.uuid,
@@ -1951,6 +2071,8 @@ class PostgresQueue {
1951
2071
  task.transport,
1952
2072
  task.concatSubject || null,
1953
2073
  JSON.stringify(task.mailOptions || []),
2074
+ task.isSettled === true,
2075
+ task.recipientResults === void 0 ? null : JSON.stringify(task.recipientResults),
1954
2076
  ]);
1955
2077
  }
1956
2078
 
@@ -1971,23 +2093,20 @@ class PostgresQueue {
1971
2093
  await this.ready();
1972
2094
 
1973
2095
  const task = normalizeRow((await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1974
- template, transport, concat_subject, mail_options
2096
+ template, transport, concat_subject, mail_options, is_settled, recipient_results
1975
2097
  FROM mail_time_queue
1976
2098
  WHERE prefix = $1
1977
2099
  AND uuid = $2
1978
2100
  LIMIT 1`, [this.prefix, uuid])).rows?.[0]);
1979
2101
 
1980
- if (!task || task.isSent === true || task.isCancelled === true) {
1981
- return false;
1982
- }
1983
-
1984
- if (!this.mailTimeInstance.keepHistory) {
1985
- return await this.remove(task);
1986
- }
1987
-
1988
- return await this.update(task, {
1989
- isCancelled: true,
1990
- });
2102
+ if (!task || task.isSent === true || task.isCancelled === true || task.isSettled === true) return false;
2103
+ const operation = this.mailTimeInstance.keepHistory
2104
+ ? 'UPDATE mail_time_queue SET is_cancelled = true, updated_at = CURRENT_TIMESTAMP'
2105
+ : 'DELETE FROM mail_time_queue';
2106
+ const result = await this.client.query(`${operation}
2107
+ WHERE prefix = $1 AND uuid = $2 AND is_sent = false AND is_cancelled = false
2108
+ AND is_settled = false AND (recipient_results IS NULL OR is_failed = false)`, [this.prefix, uuid]);
2109
+ return (result.rowCount || 0) >= 1;
1991
2110
  }
1992
2111
 
1993
2112
  /**
@@ -2013,7 +2132,7 @@ class PostgresQueue {
2013
2132
  const params = [this.prefix, value];
2014
2133
  if (isSendLeaseRemove(opts)) {
2015
2134
  params.push(opts.leaseTries, opts.leaseSendingAt);
2016
- leaseWhere = ` AND tries = $3 AND is_sending = true AND sending_at = $4 AND is_cancelled = false AND is_failed = false`;
2135
+ leaseWhere = ` AND tries = $3 AND is_sending = true AND sending_at = $4 AND is_cancelled = false AND is_failed = false AND is_sent = false AND is_settled = false`;
2017
2136
  }
2018
2137
 
2019
2138
  const res = await this.client.query(`DELETE FROM mail_time_queue
@@ -2038,6 +2157,8 @@ class PostgresQueue {
2038
2157
  }
2039
2158
 
2040
2159
  await this.ready();
2160
+ if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled'))
2161
+ && !isSendClaimUpdate(updateObj) && !isSendLeaseGuardedUpdate(updateObj)) return false;
2041
2162
 
2042
2163
  if (isAppendMailOptionUpdate(updateObj)) {
2043
2164
  const where = task.id ? 'id = $3' : 'uuid = $3';
@@ -2050,7 +2171,9 @@ class PostgresQueue {
2050
2171
  AND is_sent = false
2051
2172
  AND is_failed = false
2052
2173
  AND is_cancelled = false
2053
- AND is_sending = false`, [
2174
+ AND is_sending = false
2175
+ AND is_settled = false
2176
+ AND recipient_results IS NULL`, [
2054
2177
  JSON.stringify([updateObj.appendMailOption]),
2055
2178
  this.prefix,
2056
2179
  value,
@@ -2070,7 +2193,7 @@ class PostgresQueue {
2070
2193
  if (key === 'sendAt' && value instanceof Date) {
2071
2194
  value = +value;
2072
2195
  }
2073
- if (key === 'mailOptions') {
2196
+ if (key === 'mailOptions' || key === 'recipientResults') {
2074
2197
  value = JSON.stringify(value);
2075
2198
  }
2076
2199
 
@@ -2094,6 +2217,7 @@ class PostgresQueue {
2094
2217
  AND is_sent = false
2095
2218
  AND is_failed = false
2096
2219
  AND is_cancelled = false
2220
+ AND is_settled = false
2097
2221
  AND tries = $${triesIndex}
2098
2222
  AND (is_sending = false OR sending_at <= $${staleIndex})
2099
2223
  `;
@@ -2103,6 +2227,8 @@ class PostgresQueue {
2103
2227
  values.push(updateObj.leaseSendingAt);
2104
2228
  const sendingAtIndex = values.length;
2105
2229
  claimWhere = `
2230
+ AND is_sent = false
2231
+ AND is_settled = false
2106
2232
  AND tries = $${triesIndex}
2107
2233
  AND is_sending = true
2108
2234
  AND sending_at = $${sendingAtIndex}
@@ -2188,12 +2314,12 @@ const PRESETS = Object.freeze({
2188
2314
  transactional: Object.freeze({
2189
2315
  concatEmails: false,
2190
2316
  retries: 30,
2191
- retryDelay: 10_000,
2317
+ retryDelay: 10000,
2192
2318
  mode: 'batch',
2193
2319
  concurrency: 1,
2194
2320
  onError: defaultPresetOnError,
2195
2321
  josk: Object.freeze({
2196
- zombieTime: 120_000,
2322
+ zombieTime: 120000,
2197
2323
  }),
2198
2324
  }),
2199
2325
  otp: Object.freeze({
@@ -2201,52 +2327,52 @@ const PRESETS = Object.freeze({
2201
2327
  retries: 5,
2202
2328
  retryDelay: 2000,
2203
2329
  revolvingInterval: 1024,
2204
- sendingTimeout: 120_000,
2330
+ sendingTimeout: 120000,
2205
2331
  mode: 'batch',
2206
2332
  concurrency: 4,
2207
2333
  onError: defaultPresetOnError,
2208
2334
  josk: Object.freeze({
2209
2335
  minRevolvingDelay: 256,
2210
2336
  maxRevolvingDelay: 1024,
2211
- zombieTime: 60_000,
2337
+ zombieTime: 60000,
2212
2338
  }),
2213
2339
  }),
2214
2340
  newsletter: Object.freeze({
2215
2341
  concatEmails: true,
2216
- concatDelay: 5 * 60_000,
2342
+ concatDelay: 5 * 60000,
2217
2343
  concatSubject: 'Your updates',
2218
2344
  retries: 5,
2219
- retryDelay: 60_000,
2220
- sendingTimeout: 600_000,
2345
+ retryDelay: 60000,
2346
+ sendingTimeout: 600000,
2221
2347
  mode: 'batch',
2222
2348
  concurrency: 2,
2223
2349
  onError: defaultPresetOnError,
2224
2350
  josk: Object.freeze({
2225
- zombieTime: 300_000,
2351
+ zombieTime: 300000,
2226
2352
  }),
2227
2353
  }),
2228
2354
  marketing: Object.freeze({
2229
2355
  concatEmails: false,
2230
2356
  retries: 10,
2231
- retryDelay: 30_000,
2357
+ retryDelay: 30000,
2232
2358
  mode: 'batch',
2233
2359
  concurrency: 5,
2234
2360
  onError: defaultPresetOnError,
2235
2361
  josk: Object.freeze({
2236
- zombieTime: 180_000,
2362
+ zombieTime: 180000,
2237
2363
  }),
2238
2364
  }),
2239
2365
  notifications: Object.freeze({
2240
2366
  concatEmails: true,
2241
- concatDelay: 60_000,
2367
+ concatDelay: 60000,
2242
2368
  concatSubject: 'New activity',
2243
2369
  retries: 8,
2244
- retryDelay: 30_000,
2370
+ retryDelay: 30000,
2245
2371
  mode: 'batch',
2246
2372
  concurrency: 3,
2247
2373
  onError: defaultPresetOnError,
2248
2374
  josk: Object.freeze({
2249
- zombieTime: 180_000,
2375
+ zombieTime: 180000,
2250
2376
  }),
2251
2377
  }),
2252
2378
  alerts: Object.freeze({
@@ -2254,14 +2380,14 @@ const PRESETS = Object.freeze({
2254
2380
  retries: 20,
2255
2381
  retryDelay: 5000,
2256
2382
  revolvingInterval: 1024,
2257
- sendingTimeout: 120_000,
2383
+ sendingTimeout: 120000,
2258
2384
  mode: 'batch',
2259
2385
  concurrency: 2,
2260
2386
  onError: defaultPresetOnError,
2261
2387
  josk: Object.freeze({
2262
2388
  minRevolvingDelay: 256,
2263
2389
  maxRevolvingDelay: 1024,
2264
- zombieTime: 60_000,
2390
+ zombieTime: 60000,
2265
2391
  }),
2266
2392
  }),
2267
2393
  });
@@ -2318,6 +2444,316 @@ const mailTimePreset = (name, overrides) => {
2318
2444
  return overrides ? deepMerge(cloned, overrides) : cloned;
2319
2445
  };
2320
2446
 
2447
+ const policyError = (message) => new Error(`[mail-time] [recipientPolicies] ${message}`);
2448
+ const SIMPLE_MAILBOX = /^[^\s<>,;:"()\\\[\]@]+@[^\s<>,;:"()\\\[\]@]+$/u;
2449
+
2450
+ const normalizePolicyAddress = (value) => {
2451
+ const input = typeof value === 'string' ? value : (!Array.isArray(value) && value?.address);
2452
+ if (typeof input !== 'string' || /[\r\n]/u.test(input)) {
2453
+ throw policyError('recipient requires a simple address without line breaks');
2454
+ }
2455
+ let address = input.trim();
2456
+ if (address.includes('<') || address.includes('>')) {
2457
+ const match = address.match(/^[^<>,;:"\r\n]*<([^<>]+)>$/u);
2458
+ if (!match) throw policyError('use a simple explicit envelope.to');
2459
+ address = match[1].trim();
2460
+ }
2461
+ if (!SIMPLE_MAILBOX.test(address)) throw policyError('use a simple explicit envelope.to');
2462
+ return address.toLowerCase();
2463
+ };
2464
+
2465
+ const preparePolicyEnvelope = (compiled, previousResults = []) => {
2466
+ const recipients = new Map();
2467
+ const explicit = compiled.envelope && hasOwnProp(compiled.envelope, 'to');
2468
+ const add = (value, source, authoritative) => {
2469
+ for (const entry of Array.isArray(value) ? value : [value]) {
2470
+ let address;
2471
+ try { address = normalizePolicyAddress(entry); }
2472
+ catch (error) { if (authoritative) throw error; else continue; }
2473
+ if (!recipients.has(address)) {
2474
+ if (!authoritative) continue;
2475
+ recipients.set(address, { address, sources: [] });
2476
+ }
2477
+ const sources = recipients.get(address).sources;
2478
+ if (!sources.includes(source)) sources.push(source);
2479
+ }
2480
+ };
2481
+ if (explicit) add(compiled.envelope.to, 'envelope', true);
2482
+ for (const source of ['to', 'cc', 'bcc']) {
2483
+ if (hasOwnProp(compiled, source) && compiled[source] !== void 0) add(compiled[source], source, !explicit);
2484
+ }
2485
+ if (!recipients.size) throw policyError('envelope must contain at least one recipient');
2486
+ if (previousResults.length && (previousResults.length !== recipients.size || previousResults.some((r) => !recipients.has(r.address)))) {
2487
+ throw policyError('envelope recipient set changed between attempts');
2488
+ }
2489
+ const envelope = { to: [...recipients.keys()] };
2490
+ if (compiled.envelope && hasOwnProp(compiled.envelope, 'from')) {
2491
+ envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from);
2492
+ } else {
2493
+ const from = compiled.from || compiled.sender || compiled.replyTo;
2494
+ if (from) envelope.from = normalizePolicyAddress(from);
2495
+ }
2496
+ return { envelope, recipients: [...recipients.values()] };
2497
+ };
2498
+
2499
+ const validateRecipientPolicies = (value, queue) => {
2500
+ if (value === void 0) return null;
2501
+ if (!Array.isArray(value) || !value.length) throw policyError('recipientPolicies must be a nonempty array');
2502
+ if (queue.supportsRecipientPolicies !== true) throw policyError('queue must declare recipient policy support');
2503
+ const names = new Set();
2504
+ return value.map((provider) => {
2505
+ if (!isPlainObject(provider)) throw policyError('each provider must be a plain object');
2506
+ const name = typeof provider.name === 'string' ? provider.name.trim() : '';
2507
+ if (!name || name.length > 128 || names.has(name)) throw policyError('provider names must be unique and contain 1-128 characters');
2508
+ names.add(name);
2509
+ const failureMode = provider.failureMode === void 0 ? 'retry' : provider.failureMode;
2510
+ if (failureMode !== 'retry' && failureMode !== 'continue') throw policyError('failureMode must be retry or continue');
2511
+ const normalized = { name, failureMode };
2512
+ let count = 0;
2513
+ for (const hook of ['beforeSend', 'classifyRejections', 'observeAttempt']) {
2514
+ if (hasOwnProp(provider, hook)) {
2515
+ if (typeof provider[hook] !== 'function') throw policyError(`${hook} must be a function`);
2516
+ normalized[hook] = provider[hook].bind(provider);
2517
+ count++;
2518
+ }
2519
+ }
2520
+ if (!count) throw policyError('provider requires at least one supported hook');
2521
+ return normalized;
2522
+ });
2523
+ };
2524
+
2525
+ const validatePolicyResult = (raw, hook, context) => {
2526
+ if (raw === void 0) return [];
2527
+ if (!isPlainObject(raw)) throw policyError('hook must return a result object or nothing');
2528
+ if (raw.decisions === void 0) return [];
2529
+ if (!Array.isArray(raw.decisions)) throw policyError('decisions must be an array');
2530
+ const batch = new Set(context.recipients.map((r) => r.address));
2531
+ const attributable = new Set((context.rejections || []).map((r) => r.address).filter(Boolean));
2532
+ const decisions = new Map();
2533
+ for (const item of raw.decisions) {
2534
+ if (!isPlainObject(item)) throw policyError('decision must be an object');
2535
+ const address = normalizePolicyAddress(item.address);
2536
+ if (!batch.has(address)) throw policyError('decision address is outside the input batch');
2537
+ const allowed = hook === 'beforeSend' ? item.status === 'suppressed' : (item.status === 'retry' || item.status === 'rejected');
2538
+ if (!allowed) throw policyError('invalid decision status for this phase');
2539
+ if (item.status === 'rejected' && !attributable.has(address)) throw policyError('permanent rejection requires an attributable record');
2540
+ if (typeof item.reason !== 'string' || !item.reason.trim() || item.reason.length > 512) throw policyError('reason must contain 1-512 characters');
2541
+ const previous = decisions.get(address);
2542
+ if (previous && (previous.status !== item.status || previous.reason !== item.reason)) throw policyError('conflicting duplicate decisions');
2543
+ decisions.set(address, { address, status: item.status, reason: item.reason });
2544
+ }
2545
+ return [...decisions.values()];
2546
+ };
2547
+
2548
+ const evaluatePolicyPhase = async (providers, hook, context, report) => {
2549
+ const decisions = [];
2550
+ let retryFailure = false;
2551
+ for (const provider of providers) {
2552
+ if (typeof provider[hook] !== 'function') continue;
2553
+ try {
2554
+ const validated = validatePolicyResult(await provider[hook](context), hook, context);
2555
+ for (const decision of validated) decisions.push({ ...decision, provider: provider.name });
2556
+ } catch (error) {
2557
+ report(error, provider.name, hook);
2558
+ if (provider.failureMode !== 'continue') retryFailure = true;
2559
+ }
2560
+ }
2561
+ return { retryFailure, decisions };
2562
+ };
2563
+
2564
+ const mergePolicyResults = (previous, recipients, evaluated, details) => {
2565
+ const results = new Map(previous.map((r) => [r.address, r]));
2566
+ const accepted = new Set(details.accepted || []);
2567
+ const decisions = new Map();
2568
+ for (const decision of evaluated.decisions) {
2569
+ if (!decisions.has(decision.address)) decisions.set(decision.address, []);
2570
+ decisions.get(decision.address).push(decision);
2571
+ }
2572
+ const diagnostics = new Map();
2573
+ for (const record of details.rejections || []) {
2574
+ if (record.address && !diagnostics.has(record.address)) diagnostics.set(record.address, record);
2575
+ }
2576
+ for (const recipient of recipients) {
2577
+ const prior = results.get(recipient.address);
2578
+ if (prior && prior.status !== 'error') continue;
2579
+ const candidates = decisions.get(recipient.address) || [];
2580
+ const terminal = details.phase === 'beforeSend' ? 'suppressed' : 'rejected';
2581
+ let status = 'error';
2582
+ if (accepted.has(recipient.address)) status = 'sent';
2583
+ else if (!evaluated.retryFailure && candidates.some((d) => d.status === terminal)) status = terminal;
2584
+ const reasons = [];
2585
+ if (!evaluated.retryFailure && status !== 'sent') {
2586
+ for (const candidate of candidates) {
2587
+ if (candidate.status === (status === 'error' ? 'retry' : status)) reasons.push({ provider: candidate.provider, reason: candidate.reason });
2588
+ }
2589
+ }
2590
+ const result = { address: recipient.address, status, sources: [...recipient.sources], reasons, attempt: details.attempt, transportIndex: details.transportIndex };
2591
+ if (typeof details.transportName === 'string') result.transportName = details.transportName.slice(0, 128);
2592
+ const record = diagnostics.get(recipient.address);
2593
+ if (record) {
2594
+ for (const key of ['command', 'response', 'message']) {
2595
+ if (typeof record[key] === 'string') result[key] = record[key].slice(0, 2048);
2596
+ }
2597
+ if (Number.isFinite(record.responseCode)) result.responseCode = record.responseCode;
2598
+ }
2599
+ results.set(recipient.address, result);
2600
+ }
2601
+ return [...results.values()];
2602
+ };
2603
+
2604
+ const summarizePolicyTask = (task, isSettled) => {
2605
+ const recipients = { sent: [], error: [], suppressed: [], rejected: [] };
2606
+ for (const result of task.recipientResults || []) recipients[result.status].push(result);
2607
+ return { uuid: task.uuid, tries: task.tries, isSettled, recipients };
2608
+ };
2609
+
2610
+ const diagnosticFields = (record) => {
2611
+ const result = {};
2612
+ for (const key of ['command', 'response', 'message']) {
2613
+ if (typeof record?.[key] === 'string') result[key] = record[key].slice(0, 2048);
2614
+ }
2615
+ if (Number.isFinite(record?.responseCode)) result.responseCode = record.responseCode;
2616
+ return result;
2617
+ };
2618
+
2619
+ const normalizeRejections = (error, info, transport) => {
2620
+ const records = [];
2621
+ const ancestors = new WeakSet();
2622
+ const addressOf = (value) => {
2623
+ try { return normalizePolicyAddress(value); }
2624
+ catch { return null; }
2625
+ };
2626
+ const append = (node, address) => {
2627
+ const record = { address, ...diagnosticFields(node), transportIndex: transport.index };
2628
+ if (typeof transport.name === 'string') record.transportName = transport.name.slice(0, 128);
2629
+ if (typeof node === 'string') record.message = node.slice(0, 2048);
2630
+ records.push(record);
2631
+ };
2632
+ const visit = (node, positional = null, root = false, errorRoot = false) => {
2633
+ if (node === null || node === void 0) {
2634
+ if (!root) append(node, positional);
2635
+ return;
2636
+ }
2637
+ if (typeof node !== 'object') {
2638
+ append(node, positional);
2639
+ return;
2640
+ }
2641
+ if (ancestors.has(node)) return;
2642
+ ancestors.add(node);
2643
+ const start = records.length;
2644
+ const rejected = Array.isArray(node.rejected) ? node.rejected.map(addressOf) : [];
2645
+ for (const key of ['errors', 'rejectedErrors']) {
2646
+ if (Array.isArray(node[key])) {
2647
+ for (let i = 0; i < node[key].length; i++) visit(node[key][i], rejected[i] || null);
2648
+ }
2649
+ }
2650
+ if (records.length === start) {
2651
+ let address = positional;
2652
+ for (const key of ['recipient', 'address', 'to']) {
2653
+ if (hasOwnProp(node, key)) { address = addressOf(node[key]); break; }
2654
+ }
2655
+ if (!root || errorRoot || address || Object.keys(diagnosticFields(node)).length) append(node, address);
2656
+ }
2657
+ const attributed = new Set(records.slice(start).map((r) => r.address));
2658
+ for (const address of rejected) {
2659
+ if (address && !attributed.has(address)) append({ message: 'Recipient rejected by transport' }, address);
2660
+ }
2661
+ ancestors.delete(node);
2662
+ };
2663
+ visit(error, null, true, true);
2664
+ visit(info, null, true);
2665
+ return records;
2666
+ };
2667
+
2668
+ class RecipientPolicyLease {
2669
+ constructor({ task, queue, interval, maxRenewals, shouldAbort, report }) {
2670
+ this.__task = task;
2671
+ this.__queue = queue;
2672
+ this.__shouldAbort = shouldAbort;
2673
+ this.__report = report;
2674
+ this.__closed = false;
2675
+ this.__finishing = false;
2676
+ this.__tail = Promise.resolve();
2677
+ this.__timer = null;
2678
+ let pending = false;
2679
+ let renewals = 0;
2680
+ if (interval && maxRenewals) {
2681
+ this.__timer = setInterval(() => {
2682
+ if (!this.active || this.__finishing || renewals >= maxRenewals) {
2683
+ this.__clearTimer();
2684
+ return;
2685
+ }
2686
+ if (pending) return;
2687
+ pending = true;
2688
+ renewals++;
2689
+ this.__enqueue(async (guard) => {
2690
+ const fields = { isSending: true, sendingAt: Math.max(Date.now(), this.__task.sendingAt + 1) };
2691
+ return await this.__write(fields, guard);
2692
+ }).finally(() => { pending = false; });
2693
+ }, interval);
2694
+ this.__timer.unref?.();
2695
+ }
2696
+ }
2697
+
2698
+ get active() { return !this.__closed && !this.__shouldAbort(); }
2699
+
2700
+ __clearTimer() {
2701
+ if (this.__timer) clearInterval(this.__timer);
2702
+ this.__timer = null;
2703
+ }
2704
+
2705
+ __halt() {
2706
+ this.__clearTimer();
2707
+ this.__closed = true;
2708
+ }
2709
+
2710
+ __enqueue(operation) {
2711
+ const pending = this.__tail.then(async () => {
2712
+ if (!this.active) return false;
2713
+ const guard = { leaseTries: this.__task.tries, leaseSendingAt: this.__task.sendingAt };
2714
+ try {
2715
+ const ok = await operation(guard);
2716
+ if (!ok) this.__halt();
2717
+ return ok;
2718
+ } catch (error) {
2719
+ this.__halt();
2720
+ if (!this.__shouldAbort()) this.__report(error);
2721
+ return false;
2722
+ }
2723
+ });
2724
+ this.__tail = pending.then(() => void 0);
2725
+ return pending;
2726
+ }
2727
+
2728
+ async __write(fields, guard) {
2729
+ const ok = await this.__queue.update(this.__task, { ...fields, ...guard });
2730
+ if (ok) Object.assign(this.__task, fields);
2731
+ return ok;
2732
+ }
2733
+
2734
+ update(fields) {
2735
+ if (this.__finishing) return Promise.resolve(false);
2736
+ return this.__enqueue((guard) => this.__write(fields, guard));
2737
+ }
2738
+
2739
+ finish(fields, remove) {
2740
+ if (this.__finishing) return Promise.resolve(false);
2741
+ this.__finishing = true;
2742
+ this.__clearTimer();
2743
+ return this.__enqueue(async (guard) => {
2744
+ const ok = remove ? await this.__queue.remove(this.__task, guard) : await this.__queue.update(this.__task, { ...fields, ...guard });
2745
+ if (ok) Object.assign(this.__task, fields);
2746
+ this.__halt();
2747
+ return ok;
2748
+ });
2749
+ }
2750
+
2751
+ async stop() {
2752
+ this.__halt();
2753
+ await this.__tail;
2754
+ }
2755
+ }
2756
+
2321
2757
  const noop = () => {};
2322
2758
  const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
2323
2759
 
@@ -2386,11 +2822,19 @@ const createPool = (concurrency) => {
2386
2822
  let active = 0;
2387
2823
  let drainResolvers = [];
2388
2824
 
2825
+ const settleDrain = () => {
2826
+ if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
2827
+ const resolvers = drainResolvers;
2828
+ drainResolvers = [];
2829
+ for (const r of resolvers) r();
2830
+ }
2831
+ };
2832
+
2389
2833
  const tryStart = () => {
2390
2834
  while (active < limit && queue.length > 0) {
2391
2835
  const job = queue.shift();
2392
2836
  active++;
2393
- job.resolveSlot();
2837
+ job.resolveSlot(true);
2394
2838
  Promise.resolve()
2395
2839
  .then(job.fn)
2396
2840
  .catch((poolError) => {
@@ -2398,11 +2842,7 @@ const createPool = (concurrency) => {
2398
2842
  })
2399
2843
  .finally(() => {
2400
2844
  active--;
2401
- if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
2402
- const resolvers = drainResolvers;
2403
- drainResolvers = [];
2404
- for (const r of resolvers) r();
2405
- }
2845
+ settleDrain();
2406
2846
  tryStart();
2407
2847
  });
2408
2848
  }
@@ -2411,10 +2851,11 @@ const createPool = (concurrency) => {
2411
2851
  return {
2412
2852
  /**
2413
2853
  * Queue `fn` for execution under the concurrency limit.
2414
- * The returned Promise resolves as soon as a slot is acquired and `fn` has started.
2854
+ * The returned Promise resolves `true` as soon as a slot is acquired and `fn` has started,
2855
+ * or `false` if `cancelQueued()` dropped the job before it started (`fn` never runs).
2415
2856
  * It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
2416
2857
  * @param {() => Promise<void>} fn
2417
- * @returns {Promise<void>}
2858
+ * @returns {Promise<boolean>}
2418
2859
  */
2419
2860
  dispatch(fn) {
2420
2861
  return new Promise((resolveSlot) => {
@@ -2422,6 +2863,16 @@ const createPool = (concurrency) => {
2422
2863
  tryStart();
2423
2864
  });
2424
2865
  },
2866
+ /**
2867
+ * Drop every job still waiting for a slot. Running jobs are untouched.
2868
+ * @returns {number} dropped jobs
2869
+ */
2870
+ cancelQueued() {
2871
+ const dropped = queue.splice(0);
2872
+ for (const job of dropped) job.resolveSlot(false);
2873
+ settleDrain();
2874
+ return dropped.length;
2875
+ },
2425
2876
  drain() {
2426
2877
  if (active === 0 && queue.length === 0) {
2427
2878
  return Promise.resolve();
@@ -2519,11 +2970,32 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2519
2970
  */
2520
2971
 
2521
2972
  /**
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
2973
+ * @typedef {{ [key: string]: any, ping: () => Promise<MailTimePingResult>, setInterval: (func: (...args: any[]) => unknown, delay: number, uid: string) => Promise<string>, destroy: () => boolean, shutdown: (opts?: { timeout?: number }) => Promise<boolean>, pause: (timerId?: string) => boolean, resume: (timerId?: string) => boolean }} MailTimeScheduler
2974
+ */
2975
+
2976
+ /**
2977
+ * @typedef {string | { address: string, name?: string }} MailTimeMailbox
2978
+ */
2979
+
2980
+ /**
2981
+ * @typedef {{ address: string, sources: Array<'envelope' | 'to' | 'cc' | 'bcc'> }} MailTimePolicyRecipient
2982
+ * @typedef {{ address: string, status: 'suppressed' | 'rejected' | 'retry', reason: string }} MailTimePolicyDecision
2983
+ * @typedef {{ decisions?: MailTimePolicyDecision[] }} MailTimePolicyResult
2984
+ * @typedef {{ index: number, name?: string }} MailTimePolicyTransport
2985
+ * @typedef {{ from?: string, to: string[] }} MailTimePolicyEnvelope
2986
+ * @typedef {{ task: MailTimeTask, attempt: number, transport: MailTimePolicyTransport, envelope: MailTimePolicyEnvelope, recipients: MailTimePolicyRecipient[] }} MailTimePolicyContext
2987
+ * @typedef {MailTimePolicyContext} MailTimeBeforeSendPolicyContext
2988
+ * @typedef {{ address: string | null, command?: string, responseCode?: number, response?: string, message?: string, transportIndex: number, transportName?: string }} MailTimeStructuredRejection
2989
+ * @typedef {MailTimePolicyContext & { error?: unknown, info?: unknown, rejections: MailTimeStructuredRejection[] }} MailTimeRejectionPolicyContext
2990
+ * @typedef {{ address: string, status: 'sent' | 'error' | 'suppressed' | 'rejected', sources?: Array<'envelope' | 'to' | 'cc' | 'bcc'>, reasons: Array<{ provider: string, reason: string }>, attempt: number, transportIndex?: number, transportName?: string, command?: string, responseCode?: number, response?: string, message?: string }} MailTimeRecipientResult
2991
+ * @typedef {MailTimeRejectionPolicyContext & { decisions: MailTimeRecipientResult[] }} MailTimeRecipientAttemptContext
2992
+ * @typedef {{ uuid: string, tries: number, isSettled: boolean, recipients: { sent: MailTimeRecipientResult[], error: MailTimeRecipientResult[], suppressed: MailTimeRecipientResult[], rejected: MailTimeRecipientResult[] } }} MailTimeRecipientSummary
2993
+ * @typedef {{ name: string, failureMode?: 'retry' | 'continue', beforeSend?: (context: MailTimeBeforeSendPolicyContext) => void | MailTimePolicyResult | Promise<void | MailTimePolicyResult>, classifyRejections?: (context: MailTimeRejectionPolicyContext) => void | MailTimePolicyResult | Promise<void | MailTimePolicyResult>, observeAttempt?: (context: MailTimeRecipientAttemptContext) => void | Promise<void> }} MailTimeRecipientPolicy
2994
+ * @description Policy contexts are read-only by contract. Providers must bound their own I/O; renewal budgets do not limit hook duration.
2523
2995
  */
2524
2996
 
2525
2997
  /**
2526
- * @typedef {{ uuid: string, to?: string | string[], tries: number, sendAt: number, isSent: boolean, isCancelled: boolean, isFailed: boolean, isSending?: boolean, sendingAt?: number, template?: string | false, transport: number, concatSubject?: string | false, mailOptions: MailTimeMailOptions[] }} MailTimeTask
2998
+ * @typedef {{ uuid: string, to?: MailTimeMailbox | MailTimeMailbox[], tries: number, sendAt: number, isSent: boolean, isSettled?: boolean, recipientResults?: MailTimeRecipientResult[], isCancelled: boolean, isFailed: boolean, isSending?: boolean, sendingAt?: number, template?: string | false, transport: number, concatSubject?: string | false, mailOptions: MailTimeMailOptions[] }} MailTimeTask
2527
2999
  */
2528
3000
 
2529
3001
  /**
@@ -2531,7 +3003,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2531
3003
  */
2532
3004
 
2533
3005
  /**
2534
- * @typedef {{ ping: () => Promise<MailTimePingResult>, iterate: (opts?: MailTimeIterateOptions) => Promise<void> | void, getPendingTo: (to: string, sendAt: number) => Promise<MailTimeTask | object | null>, push: (email: MailTimeTask) => Promise<void> | void, cancel: (uuid: string) => Promise<boolean>, remove: (email: MailTimeTask | object, opts?: { leaseTries: number, leaseSendingAt: number }) => Promise<boolean>, update: (email: MailTimeTask | object, updateObj: object) => Promise<boolean>, ready?: () => Promise<void> }} CustomQueue
3006
+ * @typedef {{ ping: () => Promise<MailTimePingResult>, iterate: (opts?: MailTimeIterateOptions) => Promise<void> | void, getPendingTo: (to: string, sendAt: number) => Promise<MailTimeTask | object | null>, push: (email: MailTimeTask) => Promise<void> | void, cancel: (uuid: string) => Promise<boolean>, remove: (email: MailTimeTask | object, opts?: { leaseTries: number, leaseSendingAt: number }) => Promise<boolean>, update: (email: MailTimeTask | object, updateObj: object) => Promise<boolean>, ready?: () => Promise<void>, supportsRecipientPolicies?: boolean }} CustomQueue
2535
3007
  */
2536
3008
 
2537
3009
  /**
@@ -2539,7 +3011,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2539
3011
  */
2540
3012
 
2541
3013
  /**
2542
- * @typedef {{ [key: string]: any, to: string | string[], sendAt?: Date | number, template?: string, concatSubject?: string, text?: string | false, html?: string | false, subject?: string, accepted?: string[], rejected?: MailTimeRejectedRecipient[] }} MailTimeMailOptions
3014
+ * @typedef {{ [key: string]: any, to: MailTimeMailbox | MailTimeMailbox[], cc?: MailTimeMailbox | MailTimeMailbox[], bcc?: MailTimeMailbox | MailTimeMailbox[], sendAt?: Date | number, template?: string, concatSubject?: string, text?: string | false, html?: string | false, subject?: string, accepted?: string[], rejected?: MailTimeRejectedRecipient[] }} MailTimeMailOptions
2543
3015
  */
2544
3016
 
2545
3017
  /**
@@ -2551,10 +3023,16 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2551
3023
  */
2552
3024
 
2553
3025
  /**
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
3026
+ * @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, recipientPolicies?: MailTimeRecipientPolicy[], onError?: (error: unknown, email: MailTimeTask | null, details?: object, recipients?: MailTimeRecipientResult[], summary?: MailTimeRecipientSummary) => void | Promise<void>, onSent?: (email: MailTimeTask, details?: object, recipients?: MailTimeRecipientResult[], summary?: MailTimeRecipientSummary) => void | Promise<void>, onSuppressed?: (email: MailTimeTask, recipients: MailTimeRecipientResult[], summary: MailTimeRecipientSummary) => void | Promise<void>, onRejected?: (email: MailTimeTask, recipients: MailTimeRecipientResult[], summary: MailTimeRecipientSummary) => void | Promise<void> }} MailTimeOptions
2555
3027
  */
2556
3028
 
2557
- /** Class of MailTime */
3029
+ /**
3030
+ * Class of MailTime.
3031
+ * With recipientPolicies, terminal callbacks receive grouped recipients and a complete summary after persistence.
3032
+ * isSettled includes exhausted errors; isSent means all accepted; isFailed means an error or permanent rejection.
3033
+ * Unparseable terminal preparation failures invoke onError with an empty recipient group.
3034
+ * Hooks must bound their own I/O. Callback notifications are best-effort, not durable exactly-once delivery.
3035
+ */
2558
3036
  class MailTime {
2559
3037
  /**
2560
3038
  * Create a MailTime instance
@@ -2569,6 +3047,7 @@ class MailTime {
2569
3047
  throw new Error('[mail-time] {queue} option is required: provide a MongoQueue, RedisQueue, PostgresQueue, or CustomQueue instance');
2570
3048
  }
2571
3049
 
3050
+ this.__recipientPolicies = validateRecipientPolicies(opts.recipientPolicies, opts.queue);
2572
3051
  this.queue = opts.queue;
2573
3052
 
2574
3053
  for (let i = queueMethods.length - 1; i >= 0; i--) {
@@ -2608,6 +3087,8 @@ class MailTime {
2608
3087
  this.keepHistory = opts.keepHistory === true;
2609
3088
  this.onSent = (typeof opts.onSent === 'function') ? opts.onSent.bind(this) : noop;
2610
3089
  this.onError = (typeof opts.onError === 'function') ? opts.onError.bind(this) : noop;
3090
+ this.onSuppressed = (typeof opts.onSuppressed === 'function') ? opts.onSuppressed.bind(this) : noop;
3091
+ this.onRejected = (typeof opts.onRejected === 'function') ? opts.onRejected.bind(this) : noop;
2611
3092
 
2612
3093
  this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
2613
3094
  this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
@@ -2643,6 +3124,7 @@ class MailTime {
2643
3124
  this.__isDestroyed = false;
2644
3125
  this.__abortInFlight = false;
2645
3126
  this.__isPaused = false;
3127
+ this.__schedulerScans = 0;
2646
3128
  this.__readyPromise = null;
2647
3129
  this.__schedulerTimer = null;
2648
3130
  this.__inFlight = new Set();
@@ -2820,8 +3302,8 @@ class MailTime {
2820
3302
  /**
2821
3303
  * @memberOf MailTime
2822
3304
  * @name destroy
2823
- * @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).
2824
- * @param {{ drain?: boolean }} [opts]
3305
+ * @description Stop the scheduler and block future dispatches. Sends still waiting for a `concurrency` slot are dropped at once; their rows stay unclaimed for the next scan. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if the queue scan exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. In-flight SMTP does not count against the timeout, and the timeout does not bound SMTP drain time.
3306
+ * @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
2825
3307
  * @returns {boolean | Promise<boolean>}
2826
3308
  */
2827
3309
  destroy(opts) {
@@ -2829,14 +3311,32 @@ class MailTime {
2829
3311
  if (this.__isDestroyed) {
2830
3312
  return false;
2831
3313
  }
3314
+ if (opts?.drain === true && opts.schedulerTimeout !== void 0
3315
+ && (typeof opts.schedulerTimeout !== 'number' || !Number.isFinite(opts.schedulerTimeout) || opts.schedulerTimeout < 0)) {
3316
+ throw new Error('[mail-time] [destroy] schedulerTimeout must be a finite non-negative number');
3317
+ }
2832
3318
 
2833
3319
  this.__isDestroyed = true;
2834
3320
  this.__isPaused = false;
2835
- if (this.scheduler && typeof this.scheduler.destroy === 'function') {
2836
- this.scheduler.destroy();
3321
+ // Queued jobs never started SMTP; dropping them lets a scan blocked on a
3322
+ // pool slot return, so JoSk shutdown does not wait for in-flight sends.
3323
+ this.__pool.cancelQueued();
3324
+ if (opts?.drain === true) {
3325
+ return (async () => {
3326
+ let finished = true;
3327
+ try {
3328
+ if (this.scheduler) finished = await this.scheduler.shutdown({ timeout: opts.schedulerTimeout });
3329
+ } catch (error) {
3330
+ logError('[destroy] scheduler shutdown failed', error);
3331
+ finished = false;
3332
+ } finally {
3333
+ await this.__pool.drain();
3334
+ }
3335
+ return finished;
3336
+ })();
2837
3337
  }
2838
- if (opts && opts.drain === true && this.__pool) {
2839
- return this.__pool.drain().then(() => true);
3338
+ if (this.scheduler) {
3339
+ this.scheduler.destroy();
2840
3340
  }
2841
3341
  // No graceful drain requested: neutralize in-flight SMTP completions so a
2842
3342
  // destroyed instance performs no storage writes, no `onSent`/`onError`
@@ -2866,7 +3366,7 @@ class MailTime {
2866
3366
  /**
2867
3367
  * @memberOf MailTime
2868
3368
  * @name pause
2869
- * @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();`.
3369
+ * @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay unclaimed; 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();`.
2870
3370
  * @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
2871
3371
  */
2872
3372
  pause() {
@@ -2877,6 +3377,9 @@ class MailTime {
2877
3377
  const paused = this.scheduler.pause();
2878
3378
  if (paused) {
2879
3379
  this.__isPaused = true;
3380
+ if (this.__schedulerScans > 0) {
3381
+ this.__pool.cancelQueued();
3382
+ }
2880
3383
  }
2881
3384
  return paused;
2882
3385
  }
@@ -2957,7 +3460,8 @@ class MailTime {
2957
3460
  delete mailOptions.template;
2958
3461
  delete mailOptions.concatSubject;
2959
3462
 
2960
- if (typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
3463
+ const isMailbox = isPlainObject(mailOptions.to) && typeof mailOptions.to.address === 'string' && mailOptions.to.address.trim().length > 0;
3464
+ if (!isMailbox && typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
2961
3465
  throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
2962
3466
  }
2963
3467
 
@@ -3117,6 +3621,7 @@ class MailTime {
3117
3621
  uuid: crypto.randomUUID(),
3118
3622
  tries: 0,
3119
3623
  isSent: false,
3624
+ isSettled: false,
3120
3625
  sendAt: opts.sendAt,
3121
3626
  isFailed: false,
3122
3627
  isSending: false,
@@ -3200,9 +3705,11 @@ class MailTime {
3200
3705
  compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
3201
3706
  }
3202
3707
 
3203
- compiledOpts.html ??= '';
3204
- compiledOpts.text ??= '';
3205
- compiledOpts.subject ??= '';
3708
+ for (const field of ['html', 'text', 'subject']) {
3709
+ if (compiledOpts[field] === void 0 || compiledOpts[field] === null) {
3710
+ compiledOpts[field] = '';
3711
+ }
3712
+ }
3206
3713
 
3207
3714
  const mailOptionsList = task.mailOptions || [];
3208
3715
  const isMulti = mailOptionsList.length > 1;
@@ -3250,7 +3757,7 @@ class MailTime {
3250
3757
  }
3251
3758
 
3252
3759
  const acceptedSet = collectAcceptedSet(task);
3253
- if (acceptedSet.size > 0) {
3760
+ if (!this.__recipientPolicies && acceptedSet.size > 0) {
3254
3761
  compiledOpts.to = filterAddressField(compiledOpts.to, acceptedSet);
3255
3762
  compiledOpts.cc = filterAddressField(compiledOpts.cc, acceptedSet);
3256
3763
  compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
@@ -3463,7 +3970,7 @@ class MailTime {
3463
3970
  };
3464
3971
 
3465
3972
  const renew = async () => {
3466
- if (stopped || this.__isDestroyed || this.__abortInFlight) {
3973
+ if (stopped || this.__abortInFlight) {
3467
3974
  halt();
3468
3975
  return;
3469
3976
  }
@@ -3516,17 +4023,215 @@ class MailTime {
3516
4023
  return { stop };
3517
4024
  }
3518
4025
 
4026
+ /** @internal Serialize every policy checkpoint with this attempt's claim renewal. */
4027
+ async ___sendWithRecipientPolicies(task) {
4028
+ if (!task || task.isSent || task.isFailed || task.isCancelled || task.isSettled || this.__abortInFlight) return;
4029
+ const recoveryOnly = Array.isArray(task.recipientResults) && (task.tries >= this.maxTries
4030
+ || (task.recipientResults.length > 0 && task.recipientResults.every((r) => r.status === 'sent' || r.status === 'suppressed' || r.status === 'rejected')));
4031
+ const fields = { tries: recoveryOnly ? task.tries : task.tries + 1, isSending: true, sendingAt: Math.max(Date.now(), (task.sendingAt || 0) + 1), recipientResults: task.recipientResults || [] };
4032
+ try {
4033
+ if (!await this.queue.update(task, fields)) return;
4034
+ } catch (error) {
4035
+ if (!this.__abortInFlight) logError('[recipientPolicies] claim failed', error);
4036
+ return;
4037
+ }
4038
+ Object.assign(task, fields);
4039
+ const lease = new RecipientPolicyLease({
4040
+ task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals,
4041
+ shouldAbort: () => this.__abortInFlight,
4042
+ report: (error) => logError('[recipientPolicies] lease persistence failed', error),
4043
+ });
4044
+ try {
4045
+ if (!lease.active) return;
4046
+ if (recoveryOnly) {
4047
+ let preparationFailure = false;
4048
+ if (!task.recipientResults.length) {
4049
+ try {
4050
+ const compiled = this.___compileMailOpts(this.transports[task.transport], task);
4051
+ const prepared = preparePolicyEnvelope(compiled);
4052
+ task.recipientResults = mergePolicyResults([], prepared.recipients, { retryFailure: false, decisions: [] }, {
4053
+ phase: 'beforeSend', attempt: task.tries, transportIndex: task.transport, accepted: [...collectAcceptedSet(task)],
4054
+ });
4055
+ } catch { preparationFailure = true; }
4056
+ }
4057
+ await this.___completePolicyTask(task, lease, policyError('last attempt interrupted; completing durable recipient state'), void 0, preparationFailure);
4058
+ return;
4059
+ }
4060
+ if (!this.___isHealthyTransport(task.transport)) task.transport = this.___nextHealthyTransport(task.transport);
4061
+ const transport = this.transports[task.transport];
4062
+ const compiled = this.___compileMailOpts(transport, task);
4063
+ if (!compiled.from) compiled.from = MailTime.transportFrom(transport);
4064
+ const prepared = preparePolicyEnvelope(compiled, task.recipientResults);
4065
+ const transportName = typeof transport.name === 'string' ? transport.name : transport.options?.name;
4066
+ const details = { phase: 'beforeSend', attempt: task.tries, transportIndex: task.transport, transportName, accepted: [...collectAcceptedSet(task)] };
4067
+ task.recipientResults = mergePolicyResults(task.recipientResults, prepared.recipients, { retryFailure: false, decisions: [] }, details);
4068
+ const pending = new Set(task.recipientResults.filter((r) => r.status === 'error').map((r) => r.address));
4069
+ const context = {
4070
+ task, attempt: task.tries, transport: { index: task.transport, ...(typeof transportName === 'string' ? { name: transportName.slice(0, 128) } : {}) },
4071
+ envelope: { ...prepared.envelope, to: prepared.envelope.to.filter((a) => pending.has(a)) },
4072
+ recipients: prepared.recipients.filter((r) => pending.has(r.address)),
4073
+ };
4074
+ if (!pending.size) {
4075
+ await this.___completePolicyTask(task, lease);
4076
+ return;
4077
+ }
4078
+ const evaluated = await evaluatePolicyPhase(this.__recipientPolicies, 'beforeSend', context, (error, name, hook) => {
4079
+ if (!this.__abortInFlight) logError(`[recipientPolicies] ${name}.${hook} failed`, error);
4080
+ });
4081
+ if (!lease.active) return;
4082
+ task.recipientResults = mergePolicyResults(task.recipientResults, context.recipients, evaluated, details);
4083
+ if (!await lease.update({ recipientResults: task.recipientResults, mailOptions: task.mailOptions })) return;
4084
+ if (!lease.active) return;
4085
+ if (evaluated.retryFailure) {
4086
+ await this.___retryPolicyTask(task, lease, policyError('beforeSend provider failed'), void 0, true);
4087
+ return;
4088
+ }
4089
+ const eligible = new Set(task.recipientResults.filter((r) => r.status === 'error').map((r) => r.address));
4090
+ if (!eligible.size) {
4091
+ await this.___completePolicyTask(task, lease);
4092
+ return;
4093
+ }
4094
+ context.envelope = { ...context.envelope, to: context.envelope.to.filter((a) => eligible.has(a)) };
4095
+ context.recipients = context.recipients.filter((r) => eligible.has(r.address));
4096
+ await this.___attemptPolicyTransport(task, lease, compiled, context);
4097
+ } catch (error) {
4098
+ if (lease.active) await this.___retryPolicyTask(task, lease, error, void 0, true, true);
4099
+ } finally {
4100
+ await lease.stop();
4101
+ }
4102
+ }
4103
+
4104
+ /** @internal Persist SMTP acceptance before awaiting rejection classification or observation. */
4105
+ async ___attemptPolicyTransport(task, lease, compiled, context) {
4106
+ const transport = this.transports[context.transport.index];
4107
+ const outgoing = { ...compiled, envelope: context.envelope };
4108
+ const { error, info } = await new Promise((resolve) => {
4109
+ let called = false;
4110
+ const done = (error, info) => {
4111
+ if (called) return;
4112
+ called = true;
4113
+ resolve({ error, info });
4114
+ };
4115
+ try { transport.sendMail(outgoing, done); }
4116
+ catch (error) { done(error, void 0); }
4117
+ });
4118
+ if (!lease.active) return;
4119
+ const attempted = new Set(context.envelope.to);
4120
+ const accepted = new Set();
4121
+ for (const source of [info, error]) {
4122
+ for (const value of Array.isArray(source?.accepted) ? source.accepted : []) {
4123
+ try {
4124
+ const address = normalizePolicyAddress(value);
4125
+ if (attempted.has(address)) accepted.add(address);
4126
+ } catch { /* Transport records outside supported address forms remain unresolved. */ }
4127
+ }
4128
+ }
4129
+ const details = { phase: 'classifyRejections', attempt: task.tries, transportIndex: context.transport.index, transportName: context.transport.name, accepted: [...accepted] };
4130
+ task.recipientResults = mergePolicyResults(task.recipientResults, context.recipients, { retryFailure: false, decisions: [] }, details);
4131
+ this.___mapPolicyMailOptions(task, false);
4132
+ if (!await lease.update({ recipientResults: task.recipientResults, mailOptions: task.mailOptions })) return;
4133
+ if (!lease.active) return;
4134
+ const unresolved = context.recipients.filter((r) => !accepted.has(r.address));
4135
+ if (unresolved.length) {
4136
+ const rejections = normalizeRejections(error, info, context.transport);
4137
+ const rejectionContext = { ...context, recipients: unresolved, error, info, rejections };
4138
+ const evaluated = await evaluatePolicyPhase(this.__recipientPolicies, 'classifyRejections', rejectionContext, (hookError, name, hook) => {
4139
+ if (!this.__abortInFlight) logError(`[recipientPolicies] ${name}.${hook} failed`, hookError);
4140
+ });
4141
+ if (!lease.active) return;
4142
+ task.recipientResults = mergePolicyResults(task.recipientResults, unresolved, evaluated, { ...details, rejections });
4143
+ const observation = { ...rejectionContext, decisions: task.recipientResults };
4144
+ for (const provider of this.__recipientPolicies) {
4145
+ if (!lease.active) return;
4146
+ if (typeof provider.observeAttempt !== 'function') continue;
4147
+ try { await provider.observeAttempt(observation); }
4148
+ catch (hookError) {
4149
+ if (!this.__abortInFlight) logError(`[recipientPolicies] ${provider.name}.observeAttempt failed`, hookError);
4150
+ }
4151
+ }
4152
+ }
4153
+ if (!lease.active) return;
4154
+ if (task.recipientResults.some((r) => r.status === 'error')) {
4155
+ await this.___retryPolicyTask(task, lease, error || policyError('recipients remain unaccepted'), info, !!error || accepted.size === 0);
4156
+ } else {
4157
+ await this.___completePolicyTask(task, lease, error, info);
4158
+ }
4159
+ }
4160
+
4161
+ /** @internal Keep legacy per-mail-option state limited to actual envelope recipients. */
4162
+ ___mapPolicyMailOptions(task, terminal) {
4163
+ const results = new Map(task.recipientResults.map((r) => [r.address, r]));
4164
+ for (const option of task.mailOptions || []) {
4165
+ const addresses = new Set();
4166
+ for (const field of ['to', 'cc', 'bcc']) {
4167
+ const values = Array.isArray(option[field]) ? option[field] : [option[field]];
4168
+ for (const value of values) {
4169
+ try { addresses.add(normalizePolicyAddress(value)); } catch { /* Explicit envelopes permit complex headers. */ }
4170
+ }
4171
+ }
4172
+ const accepted = new Set(Array.isArray(option.accepted) ? option.accepted : []);
4173
+ const rejected = [];
4174
+ for (const address of addresses) {
4175
+ const result = results.get(address);
4176
+ if (result?.status === 'sent') accepted.add(address);
4177
+ if (terminal && (result?.status === 'error' || result?.status === 'rejected')) {
4178
+ rejected.push({ address, error: result.message || result.response || result.reasons[0]?.reason || 'Recipient not accepted by transport' });
4179
+ }
4180
+ }
4181
+ option.accepted = [...accepted];
4182
+ if (terminal) option.rejected = rejected;
4183
+ }
4184
+ }
4185
+
4186
+ /** @internal Release a policy claim with all durable outcomes, or settle at exhaustion. */
4187
+ async ___retryPolicyTask(task, lease, error, info, allowRotation, preparationFailure = false) {
4188
+ if (!lease.active) return false;
4189
+ if (task.tries >= this.maxTries) return await this.___completePolicyTask(task, lease, error, info, preparationFailure);
4190
+ let transport = task.transport;
4191
+ if (allowRotation && this.strategy === 'backup' && this.transports.length > 1
4192
+ && task.tries % this.failsToNext === 0 && this.___mayFailOver(error, info, task)) transport = this.___nextHealthyTransport(transport);
4193
+ return await lease.finish({ isSending: false, sendingAt: 0, sendAt: Date.now() + this.retryDelay, transport,
4194
+ recipientResults: task.recipientResults, mailOptions: task.mailOptions }, false);
4195
+ }
4196
+
4197
+ /** @internal Settle truthfully before invoking any best-effort terminal notification. */
4198
+ async ___completePolicyTask(task, lease, error, info, preparationFailure = false) {
4199
+ if (!lease.active) return false;
4200
+ this.___mapPolicyMailOptions(task, true);
4201
+ const results = task.recipientResults;
4202
+ const fields = {
4203
+ isSettled: true, isSent: results.length > 0 && results.every((r) => r.status === 'sent'),
4204
+ isFailed: preparationFailure || results.some((r) => r.status === 'error' || r.status === 'rejected'),
4205
+ isSending: false, sendingAt: 0, recipientResults: results, mailOptions: task.mailOptions,
4206
+ };
4207
+ const completed = await lease.finish(fields, !this.keepHistory);
4208
+ if (completed && !this.__abortInFlight) this.___notifyPolicyGroups(task, error, info, preparationFailure);
4209
+ return completed;
4210
+ }
4211
+
4212
+ /** @internal Empty groups are silent except unparseable terminal preparation failures. */
4213
+ ___notifyPolicyGroups(task, error, info, preparationFailure) {
4214
+ const summary = summarizePolicyTask(task, true);
4215
+ const groups = summary.recipients;
4216
+ if (groups.sent.length) callHook('onSent', this.onSent, task, info, groups.sent, summary);
4217
+ if (!this.__abortInFlight && (groups.error.length || (preparationFailure && !task.recipientResults.length))) {
4218
+ callHook('onError', this.onError, error || policyError('recipients remain unaccepted'), task, info, groups.error, summary);
4219
+ }
4220
+ if (!this.__abortInFlight && groups.suppressed.length) callHook('onSuppressed', this.onSuppressed, task, groups.suppressed, summary);
4221
+ if (!this.__abortInFlight && groups.rejected.length) callHook('onRejected', this.onRejected, task, groups.rejected, summary);
4222
+ }
4223
+
3519
4224
  /**
3520
4225
  * @async
3521
4226
  * @internal
3522
4227
  * @memberOf MailTime
3523
4228
  * @name ___dispatch
3524
- * @description Queue full-lifecycle send for `task` under the bounded send pool. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
4229
+ * @description Queue full-lifecycle send for `task` under the bounded send pool. No-op after `destroy()`, or while paused during a scheduler-driven scan; `destroy()` and `pause()` also drop jobs still waiting for a slot. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
3525
4230
  * @param {MailTimeTask} task - email's task object from Storage
3526
4231
  * @returns {Promise<void 0>}
3527
4232
  */
3528
4233
  async ___dispatch(task) {
3529
- if (this.__isDestroyed) {
4234
+ if (this.___isStopped) {
3530
4235
  return;
3531
4236
  }
3532
4237
  if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
@@ -3537,9 +4242,9 @@ class MailTime {
3537
4242
  return;
3538
4243
  }
3539
4244
  this.__inFlight.add(task.uuid);
3540
- await this.__pool.dispatch(async () => {
4245
+ const started = await this.__pool.dispatch(async () => {
3541
4246
  try {
3542
- if (this.__isDestroyed) {
4247
+ if (this.___isStopped) {
3543
4248
  return;
3544
4249
  }
3545
4250
  await this.___send(task);
@@ -3547,6 +4252,20 @@ class MailTime {
3547
4252
  this.__inFlight.delete(task.uuid);
3548
4253
  }
3549
4254
  });
4255
+ if (!started) {
4256
+ this.__inFlight.delete(task.uuid);
4257
+ }
4258
+ }
4259
+
4260
+ /**
4261
+ * @internal
4262
+ * @memberOf MailTime
4263
+ * @name ___isStopped
4264
+ * @description `true` after `destroy()`, or while paused during a scheduler-driven scan. Queue adapters check it to end an `iterate` scan early.
4265
+ * @returns {boolean}
4266
+ */
4267
+ get ___isStopped() {
4268
+ return this.__isDestroyed || (this.__isPaused && this.__schedulerScans > 0);
3550
4269
  }
3551
4270
 
3552
4271
  /**
@@ -3559,6 +4278,12 @@ class MailTime {
3559
4278
  * @returns {Promise<void 0>}
3560
4279
  */
3561
4280
  async ___send(task) {
4281
+ if (this.__recipientPolicies) return await this.___sendWithRecipientPolicies(task);
4282
+ if (task?.isSettled === true) return;
4283
+ if (Array.isArray(task?.recipientResults)) {
4284
+ logError('[recipientPolicies] active policy state requires a policy-configured worker', task.uuid);
4285
+ return;
4286
+ }
3562
4287
  this.__debug('[private send]', task);
3563
4288
  try {
3564
4289
  if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
@@ -3755,10 +4480,15 @@ class MailTime {
3755
4480
  return;
3756
4481
  }
3757
4482
  const limit = this.mode === 'one' ? 1 : Infinity;
3758
- return await this.queue.iterate({
3759
- limit,
3760
- sendingTimeout: this.sendingTimeout,
3761
- });
4483
+ this.__schedulerScans++;
4484
+ try {
4485
+ return await this.queue.iterate({
4486
+ limit,
4487
+ sendingTimeout: this.sendingTimeout,
4488
+ });
4489
+ } finally {
4490
+ this.__schedulerScans--;
4491
+ }
3762
4492
  }
3763
4493
 
3764
4494
  /**