mail-time 5.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs CHANGED
@@ -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
 
@@ -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,8 +745,15 @@ 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
732
- * @property {(options: object) => AsyncIterable<string|string[]>} scanIterator
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]
752
+ * @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
753
+ * @property {(key: string, field: string) => Promise<string|null|undefined>} [hGet]
754
+ * @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
755
+ * @property {(sha: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [evalSha]
756
+ * @property {(script: string) => Promise<string>} [scriptLoad]
733
757
  * @property {(key: string) => Promise<unknown>} [watch]
734
758
  * @property {() => Promise<unknown>} [unwatch]
735
759
  * @property {() => object} [multi]
@@ -739,10 +763,189 @@ class MongoQueue {
739
763
  * @typedef {object} RedisQueueOption
740
764
  * @property {RedisClient} client
741
765
  * @property {string} [prefix]
766
+ * @property {boolean} [useHashTags] - Use Redis Cluster hash-tag keys (`mailtime:{prefix}:*`). Default keeps existing standalone keys.
742
767
  */
743
768
 
744
769
  const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
745
770
  const DEFAULT_PREFIX$1 = 'default';
771
+ const VALID_PREFIX = /^[A-Za-z0-9_\-:.]+$/;
772
+ const TAGGED_ITERATE_LIMIT = 100;
773
+ const clientTransactions = new WeakMap();
774
+
775
+ const PUSH_TAGGED_TASK_SCRIPT = `
776
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
777
+ redis.call('ZADD', KEYS[2], tonumber(ARGV[3]), ARGV[1])
778
+ if #KEYS > 3 then
779
+ redis.call('SET', KEYS[3], ARGV[1], 'PXAT', tonumber(ARGV[4]))
780
+ redis.call('ZADD', KEYS[4], tonumber(ARGV[4]), KEYS[3])
781
+ end
782
+ return 1
783
+ `;
784
+
785
+ const ITERATE_TAGGED_TASKS_SCRIPT = `
786
+ local now = tonumber(ARGV[1])
787
+ local maxTries = tonumber(ARGV[2])
788
+ local sendingTimeout = tonumber(ARGV[3])
789
+ local limit = tonumber(ARGV[4])
790
+ local scanLimit = tonumber(ARGV[5])
791
+ local tasks = {}
792
+ if #KEYS > 2 then
793
+ local expiredPointers = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now, 'LIMIT', 0, 100)
794
+ for _, pointerKey in ipairs(expiredPointers) do
795
+ redis.call('DEL', pointerKey)
796
+ redis.call('ZREM', KEYS[3], pointerKey)
797
+ end
798
+ end
799
+ local due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'LIMIT', 0, scanLimit)
800
+ for _, uuid in ipairs(due) do
801
+ local payload = redis.call('HGET', KEYS[1], uuid)
802
+ if not payload then
803
+ redis.call('ZREM', KEYS[2], uuid)
804
+ else
805
+ local task = cjson.decode(payload)
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
809
+ redis.call('ZREM', KEYS[2], uuid)
810
+ elseif task.isSending then
811
+ local eligibleAt = tonumber(task.sendingAt or 0) + sendingTimeout
812
+ if eligibleAt > now then
813
+ redis.call('ZADD', KEYS[2], eligibleAt, uuid)
814
+ else
815
+ table.insert(tasks, payload)
816
+ end
817
+ elseif tonumber(task.sendAt or 0) <= now then
818
+ table.insert(tasks, payload)
819
+ else
820
+ redis.call('ZADD', KEYS[2], tonumber(task.sendAt), uuid)
821
+ end
822
+ if #tasks >= limit then
823
+ break
824
+ end
825
+ end
826
+ end
827
+
828
+ return cjson.encode(tasks)
829
+ `;
830
+
831
+ const UPDATE_TAGGED_TASK_SCRIPT = `
832
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
833
+ if not payload then
834
+ redis.call('ZREM', KEYS[2], ARGV[1])
835
+ return 0
836
+ end
837
+
838
+ local task = cjson.decode(payload)
839
+ local update = cjson.decode(ARGV[2])
840
+ local mode = ARGV[3]
841
+ local now = tonumber(ARGV[4])
842
+ local sendingTimeout = tonumber(ARGV[5])
843
+ local expectedTries = tonumber(ARGV[6])
844
+ local leaseSendingAt = tonumber(ARGV[7])
845
+ if payload ~= ARGV[8] then return -1 end
846
+
847
+ if mode == 'claim' then
848
+ if task.isSent or task.isFailed or task.isCancelled or task.isSettled or tonumber(task.tries or 0) ~= expectedTries then
849
+ return 0
850
+ end
851
+ if task.isSending and tonumber(task.sendingAt or 0) > now - sendingTimeout then
852
+ return 0
853
+ end
854
+ elseif mode == 'lease' then
855
+ if task.isSent or task.isSettled or task.isCancelled or task.isFailed or not task.isSending
856
+ or tonumber(task.tries or 0) ~= expectedTries
857
+ or tonumber(task.sendingAt or 0) ~= leaseSendingAt then
858
+ return 0
859
+ end
860
+ end
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
866
+ for key, value in pairs(update) do
867
+ task[key] = value
868
+ end
869
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[9])
870
+
871
+ if task.isSent or task.isFailed or task.isCancelled or task.isSettled then
872
+ redis.call('ZREM', KEYS[2], ARGV[1])
873
+ if #KEYS > 3 then
874
+ local concatUuid = redis.call('GET', KEYS[3])
875
+ if concatUuid == ARGV[1] then
876
+ redis.call('DEL', KEYS[3])
877
+ redis.call('ZREM', KEYS[4], KEYS[3])
878
+ elseif not concatUuid then
879
+ redis.call('ZREM', KEYS[4], KEYS[3])
880
+ end
881
+ end
882
+ elseif task.isSending then
883
+ redis.call('ZADD', KEYS[2], tonumber(task.sendingAt or now) + sendingTimeout, ARGV[1])
884
+ else
885
+ redis.call('ZADD', KEYS[2], tonumber(task.sendAt), ARGV[1])
886
+ end
887
+ return 1
888
+ `;
889
+
890
+ const APPEND_TAGGED_MAIL_OPTION_SCRIPT = `
891
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
892
+ if not payload then
893
+ return 0
894
+ end
895
+ local task = cjson.decode(payload)
896
+ if task.isSending or task.isSent or task.isFailed or task.isCancelled or task.isSettled or type(task.recipientResults) == 'table' then
897
+ return 0
898
+ end
899
+ if payload ~= ARGV[3] then return -1 end
900
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[4])
901
+ return 1
902
+ `;
903
+
904
+ const REMOVE_TAGGED_TASK_SCRIPT = `
905
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
906
+ if not payload then
907
+ redis.call('ZREM', KEYS[2], ARGV[1])
908
+ return 0
909
+ end
910
+ local task = cjson.decode(payload)
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
914
+ or tonumber(task.tries or 0) ~= tonumber(ARGV[3])
915
+ or tonumber(task.sendingAt or 0) ~= tonumber(ARGV[4])) then
916
+ return 0
917
+ end
918
+ redis.call('HDEL', KEYS[1], ARGV[1])
919
+ redis.call('ZREM', KEYS[2], ARGV[1])
920
+ if #KEYS > 3 then
921
+ local concatUuid = redis.call('GET', KEYS[3])
922
+ if concatUuid == ARGV[1] then
923
+ redis.call('DEL', KEYS[3])
924
+ redis.call('ZREM', KEYS[4], KEYS[3])
925
+ elseif not concatUuid then
926
+ redis.call('ZREM', KEYS[4], KEYS[3])
927
+ end
928
+ end
929
+ return 1
930
+ `;
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
+
944
+ const sha1Hex = (string) => crypto.createHash('sha1').update(string).digest('hex');
945
+
946
+ const isNoScriptError = (error) => {
947
+ return !!error && (error.code === 'NOSCRIPT' || (typeof error.message === 'string' && error.message.includes('NOSCRIPT')));
948
+ };
746
949
 
747
950
  const canReleaseLease = (currentTask, updateObj) => {
748
951
  return currentTask
@@ -750,6 +953,8 @@ const canReleaseLease = (currentTask, updateObj) => {
750
953
  && currentTask.isSending === true
751
954
  && (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) === updateObj.leaseSendingAt
752
955
  && currentTask.isCancelled !== true
956
+ && currentTask.isSent !== true
957
+ && currentTask.isSettled !== true
753
958
  && currentTask.isFailed !== true;
754
959
  };
755
960
 
@@ -757,7 +962,7 @@ const canClaimTask = (currentTask, task, now, sendingTimeout) => {
757
962
  if (!currentTask) {
758
963
  return false;
759
964
  }
760
- if (currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
965
+ if (currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true || currentTask.isSettled === true) {
761
966
  return false;
762
967
  }
763
968
  if (currentTask.tries !== task.tries) {
@@ -776,11 +981,11 @@ const isIterateCandidate = (candidate, now, sendingTimeout, maxTries) => {
776
981
  if (!candidate || typeof candidate !== 'object') {
777
982
  return false;
778
983
  }
779
- if (candidate.isSent === true || candidate.isFailed === true || candidate.isCancelled === true) {
984
+ if (candidate.isSent === true || candidate.isFailed === true || candidate.isCancelled === true || candidate.isSettled === true) {
780
985
  return false;
781
986
  }
782
987
  const tries = typeof candidate.tries === 'number' ? candidate.tries : 0;
783
- if (tries >= maxTries) {
988
+ if (tries >= maxTries && !(Array.isArray(candidate.recipientResults) && candidate.isSending === true)) {
784
989
  return false;
785
990
  }
786
991
  if (candidate.isSending === true) {
@@ -808,6 +1013,7 @@ class RedisQueue {
808
1013
  */
809
1014
  constructor (opts) {
810
1015
  this.name = 'redis-queue';
1016
+ this.supportsRecipientPolicies = true;
811
1017
  if (!opts || typeof opts !== 'object') {
812
1018
  throw new TypeError('[mail-time] Configuration object must be passed into RedisQueue constructor');
813
1019
  }
@@ -816,7 +1022,21 @@ class RedisQueue {
816
1022
  throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
817
1023
  }
818
1024
 
1025
+ if (opts.useHashTags !== undefined && typeof opts.useHashTags !== 'boolean') {
1026
+ throw new TypeError(`[mail-time] [RedisQueue] {useHashTags} option must be a boolean (received: ${typeof opts.useHashTags})`);
1027
+ }
1028
+
819
1029
  this.client = opts.client;
1030
+ this.useHashTags = opts.useHashTags === true;
1031
+ this.__scriptSources = {
1032
+ push: PUSH_TAGGED_TASK_SCRIPT,
1033
+ iterate: ITERATE_TAGGED_TASKS_SCRIPT,
1034
+ update: UPDATE_TAGGED_TASK_SCRIPT,
1035
+ append: APPEND_TAGGED_MAIL_OPTION_SCRIPT,
1036
+ remove: REMOVE_TAGGED_TASK_SCRIPT,
1037
+ };
1038
+ this.__scriptShas = Object.fromEntries(Object.entries(this.__scriptSources).map(([name, source]) => [name, sha1Hex(source)]));
1039
+ this.__loadedShas = new Set();
820
1040
  if (typeof opts.prefix === 'string') {
821
1041
  this.__applyPrefix(opts.prefix);
822
1042
  }
@@ -824,8 +1044,16 @@ class RedisQueue {
824
1044
 
825
1045
  /** @internal */
826
1046
  __applyPrefix(prefix) {
1047
+ if (this.useHashTags && !VALID_PREFIX.test(prefix)) {
1048
+ throw new Error(`[mail-time] [RedisQueue] {prefix} option must match ${VALID_PREFIX} when {useHashTags} is true (received: "${prefix}")`);
1049
+ }
827
1050
  this.prefix = prefix;
828
- this.uniqueName = `mailtime:${prefix}`;
1051
+ this.uniqueName = this.useHashTags ? `mailtime:{${prefix}}` : `mailtime:${prefix}`;
1052
+ if (this.useHashTags) {
1053
+ this.lettersKey = `${this.uniqueName}:letters`;
1054
+ this.scheduleKey = `${this.uniqueName}:schedule`;
1055
+ this.concatKeysKey = `${this.uniqueName}:concatkeys`;
1056
+ }
829
1057
  }
830
1058
 
831
1059
  /** @internal */
@@ -840,6 +1068,60 @@ class RedisQueue {
840
1068
  debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
841
1069
  }
842
1070
 
1071
+ /** @internal */
1072
+ async __runScript(scriptKey, options) {
1073
+ const source = this.__scriptSources[scriptKey];
1074
+ const sha = this.__scriptShas[scriptKey];
1075
+ if (!source || !sha) {
1076
+ throw new Error(`[mail-time] [RedisQueue] unknown script "${scriptKey}"`);
1077
+ }
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
+
1090
+ if (this.__loadedShas.has(sha) && typeof this.client.evalSha === 'function') {
1091
+ try {
1092
+ return await this.client.evalSha(sha, options);
1093
+ } catch (error) {
1094
+ if (!isNoScriptError(error)) {
1095
+ throw error;
1096
+ }
1097
+ this.__loadedShas.delete(sha);
1098
+ }
1099
+ }
1100
+
1101
+ if (typeof this.client.scriptLoad === 'function' && typeof this.client.evalSha === 'function') {
1102
+ try {
1103
+ await this.client.scriptLoad(source);
1104
+ this.__loadedShas.add(sha);
1105
+ return await this.client.evalSha(sha, options);
1106
+ } catch (error) {
1107
+ if (!isNoScriptError(error)) {
1108
+ this.__debug(`[script:${scriptKey}] scriptLoad failed; falling back to EVAL`, error);
1109
+ }
1110
+ }
1111
+ }
1112
+
1113
+ if (typeof this.client.eval !== 'function') {
1114
+ throw new Error('[mail-time] [RedisQueue] Redis Cluster client must support EVAL');
1115
+ }
1116
+ return await this.client.eval(source, options);
1117
+ }
1118
+
1119
+ /** @internal */
1120
+ __getTaggedConcatKey(to) {
1121
+ this.__ensurePrefix();
1122
+ return `${this.uniqueName}:concatletter:${to}`;
1123
+ }
1124
+
843
1125
  /**
844
1126
  * @async
845
1127
  * @memberOf RedisQueue
@@ -872,7 +1154,9 @@ class RedisQueue {
872
1154
  }
873
1155
 
874
1156
  try {
875
- 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();
876
1160
  if (ping === 'PONG') {
877
1161
  return {
878
1162
  status: 'OK',
@@ -917,6 +1201,26 @@ class RedisQueue {
917
1201
  const maxTries = (this.mailTimeInstance && typeof this.mailTimeInstance.maxTries === 'number')
918
1202
  ? this.mailTimeInstance.maxTries
919
1203
  : 60;
1204
+
1205
+ if (this.useHashTags) {
1206
+ this.__ensurePrefix();
1207
+ const dispatchLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TAGGED_ITERATE_LIMIT;
1208
+ const payload = await this.__runScript('iterate', {
1209
+ keys: [this.lettersKey, this.scheduleKey, this.concatKeysKey],
1210
+ arguments: [`${now}`, `${maxTries}`, `${sendingTimeout}`, `${dispatchLimit}`, `${Math.max(dispatchLimit * 20, 100)}`],
1211
+ });
1212
+ const candidates = payload ? JSON.parse(String(payload)) : [];
1213
+ if (!Array.isArray(candidates)) {
1214
+ return;
1215
+ }
1216
+ for (const value of candidates) {
1217
+ const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
1218
+ if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
1219
+ await this.mailTimeInstance.___dispatch(candidate);
1220
+ }
1221
+ }
1222
+ return;
1223
+ }
920
1224
  let dispatched = 0;
921
1225
 
922
1226
  const matchPattern = this.__getKey('*', 'sendat');
@@ -973,20 +1277,21 @@ class RedisQueue {
973
1277
  return null;
974
1278
  }
975
1279
 
976
- const concatKey = this.__getKey(to, 'concatletter');
1280
+ const concatKey = this.useHashTags ? this.__getTaggedConcatKey(to) : this.__getKey(to, 'concatletter');
977
1281
  const uuid = await this.client.get(concatKey);
978
1282
  if (!uuid) {
979
1283
  return null;
980
1284
  }
981
1285
 
982
- const letterKey = this.__getKey(uuid, 'letter');
983
- const taskJSON = await this.client.get(letterKey);
1286
+ const taskJSON = this.useHashTags
1287
+ ? await this.client.hGet(this.lettersKey, uuid)
1288
+ : await this.client.get(this.__getKey(uuid, 'letter'));
984
1289
  if (!taskJSON) {
985
1290
  return null;
986
1291
  }
987
1292
 
988
1293
  const task = JSON.parse(taskJSON);
989
- if (!task || task.isSent === true || task.isCancelled === true || task.isFailed === true || task.sendAt > sendAt || task.isSending === true || task.tries >= this.mailTimeInstance.maxTries) {
1294
+ 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) {
990
1295
  return null;
991
1296
  }
992
1297
 
@@ -1011,6 +1316,19 @@ class RedisQueue {
1011
1316
  task.sendAt = +task.sendAt;
1012
1317
  }
1013
1318
 
1319
+ if (this.useHashTags) {
1320
+ this.__ensurePrefix();
1321
+ const keys = [this.lettersKey, this.scheduleKey];
1322
+ const args = [task.uuid, JSON.stringify(task), `${+task.sendAt}`];
1323
+ if (task.to) {
1324
+ keys.push(this.__getTaggedConcatKey(task.to));
1325
+ keys.push(this.concatKeysKey);
1326
+ args.push(`${task.sendAt - 128}`);
1327
+ }
1328
+ await this.__runScript('push', { keys, arguments: args });
1329
+ return;
1330
+ }
1331
+
1014
1332
  const letterKey = this.__getKey(task.uuid, 'letter');
1015
1333
  const sendatKey = this.__getKey(task.uuid, 'sendat');
1016
1334
  const taskJSON = JSON.stringify(task);
@@ -1046,30 +1364,59 @@ class RedisQueue {
1046
1364
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
1047
1365
  */
1048
1366
  async cancel(uuid) {
1367
+ return await this.__serialize(() => this.__cancel(uuid));
1368
+ }
1369
+
1370
+ /** @internal */
1371
+ async __serialize(operation) {
1372
+ if (this.useHashTags) return await operation();
1373
+ const previous = clientTransactions.get(this.client) || Promise.resolve();
1374
+ const pending = previous.then(operation);
1375
+ const settled = pending.catch(() => void 0);
1376
+ clientTransactions.set(this.client, settled);
1377
+ try { return await pending; }
1378
+ finally { if (clientTransactions.get(this.client) === settled) clientTransactions.delete(this.client); }
1379
+ }
1380
+
1381
+ /** @internal */
1382
+ async __cancel(uuid) {
1049
1383
  this.__debug('[cancel]', uuid);
1050
1384
  if (typeof uuid !== 'string') {
1051
1385
  return false;
1052
1386
  }
1053
1387
 
1054
- await this.client.del(this.__getKey(uuid, 'sendat'));
1055
- const letterKey = this.__getKey(uuid, 'letter');
1056
- const taskJSON = await this.client.get(letterKey);
1057
- if (!taskJSON) {
1058
- return false;
1059
- }
1060
-
1061
- const task = JSON.parse(taskJSON);
1062
- if (!task || task.isSent === true || task.isCancelled === true) {
1388
+ this.__ensurePrefix();
1389
+ const atomic = typeof this.client.watch === 'function' && typeof this.client.multi === 'function';
1390
+ const letterKey = this.__getKey(uuid);
1391
+ try {
1392
+ if (!this.useHashTags && atomic) await this.client.watch(letterKey);
1393
+ const payload = this.useHashTags ? await this.client.hGet(this.lettersKey, uuid) : await this.client.get(letterKey);
1394
+ const task = payload ? JSON.parse(payload) : null;
1395
+ if (!task || task.isSent || task.isCancelled || task.isSettled || (task.recipientResults != null && task.isFailed)) return false;
1396
+ if (this.useHashTags) {
1397
+ const keys = [this.lettersKey, this.scheduleKey];
1398
+ if (task.to) keys.push(this.__getTaggedConcatKey(task.to), this.concatKeysKey);
1399
+ const keep = this.mailTimeInstance.keepHistory;
1400
+ const args = keep
1401
+ ? [uuid, JSON.stringify({ isCancelled: true }), 'cancel', `${Date.now()}`, `${this.mailTimeInstance.sendingTimeout || 300000}`, '0', '0', payload, JSON.stringify({ ...task, isCancelled: true })]
1402
+ : [uuid, 'cancel', '0', '0'];
1403
+ return Number(await this.__runScript(keep ? 'update' : 'remove', { keys, arguments: args })) >= 1;
1404
+ }
1405
+ if (!atomic) {
1406
+ if (task.recipientResults != null) return false;
1407
+ return this.mailTimeInstance.keepHistory ? await this.__update(task, { isCancelled: true }) : await this.__remove(task);
1408
+ }
1409
+ const multi = this.client.multi();
1410
+ if (this.mailTimeInstance.keepHistory) multi.set(letterKey, JSON.stringify({ ...task, isCancelled: true }));
1411
+ else multi.del(letterKey);
1412
+ multi.del(this.__getKey(uuid, 'sendat'));
1413
+ return (await multi.exec()) !== null;
1414
+ } catch (error) {
1415
+ logError('[cancel] storage error', error);
1063
1416
  return false;
1417
+ } finally {
1418
+ if (!this.useHashTags && atomic) await this.client.unwatch?.();
1064
1419
  }
1065
-
1066
- if (!this.mailTimeInstance.keepHistory) {
1067
- return await this.remove(task);
1068
- }
1069
-
1070
- return await this.update(task, {
1071
- isCancelled: true,
1072
- });
1073
1420
  }
1074
1421
 
1075
1422
  /**
@@ -1082,11 +1429,40 @@ class RedisQueue {
1082
1429
  * @returns {Promise<boolean>} returns `true` if removed or `false` if not found
1083
1430
  */
1084
1431
  async remove(task, opts) {
1432
+ return await this.__serialize(() => this.__remove(task, opts));
1433
+ }
1434
+
1435
+ /** @internal */
1436
+ async __remove(task, opts) {
1085
1437
  this.__debug('[remove]', task?.uuid);
1086
1438
  if (!task || typeof task !== 'object' || typeof task.uuid !== 'string') {
1087
1439
  return false;
1088
1440
  }
1089
1441
 
1442
+ if (this.useHashTags) {
1443
+ this.__ensurePrefix();
1444
+ try {
1445
+ const keys = [this.lettersKey, this.scheduleKey];
1446
+ if (task.to) {
1447
+ keys.push(this.__getTaggedConcatKey(task.to));
1448
+ keys.push(this.concatKeysKey);
1449
+ }
1450
+ const result = await this.__runScript('remove', {
1451
+ keys,
1452
+ arguments: [
1453
+ task.uuid,
1454
+ isSendLeaseRemove(opts) ? 'lease' : 'plain',
1455
+ `${opts?.leaseTries || 0}`,
1456
+ `${opts?.leaseSendingAt || 0}`,
1457
+ ],
1458
+ });
1459
+ return Number(result) >= 1;
1460
+ } catch (opError) {
1461
+ logError('[remove] [tagged] [opError]', opError);
1462
+ return false;
1463
+ }
1464
+ }
1465
+
1090
1466
  const letterKey = this.__getKey(task.uuid, 'letter');
1091
1467
  if (isSendLeaseRemove(opts)) {
1092
1468
  if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
@@ -1104,6 +1480,8 @@ class RedisQueue {
1104
1480
  || currentTask.isSending !== true
1105
1481
  || (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) !== opts.leaseSendingAt
1106
1482
  || currentTask.isCancelled === true
1483
+ || currentTask.isSent === true
1484
+ || currentTask.isSettled === true
1107
1485
  || currentTask.isFailed === true) {
1108
1486
  await this.client.unwatch?.();
1109
1487
  return false;
@@ -1147,6 +1525,11 @@ class RedisQueue {
1147
1525
  * @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
1148
1526
  */
1149
1527
  async update(task, updateObj) {
1528
+ return await this.__serialize(() => this.__update(task, updateObj));
1529
+ }
1530
+
1531
+ /** @internal */
1532
+ async __update(task, updateObj) {
1150
1533
  this.__debug('[update]', task?.uuid);
1151
1534
  if (!task || typeof task !== 'object' || typeof task.uuid !== 'string' || !updateObj || typeof updateObj !== 'object') {
1152
1535
  return false;
@@ -1157,10 +1540,43 @@ class RedisQueue {
1157
1540
  const isClaim = isSendClaimUpdate(updateObj);
1158
1541
  const isAppend = isAppendMailOptionUpdate(updateObj);
1159
1542
  const isLeaseRelease = isSendLeaseGuardedUpdate(updateObj);
1543
+ if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled')) && !isClaim && !isLeaseRelease) return false;
1160
1544
  const now = isClaim && typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
1161
1545
  const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
1162
1546
 
1163
1547
  try {
1548
+ if (this.useHashTags) {
1549
+ // JS serialization preserves empty arrays and scalar values that Lua cjson changes.
1550
+ // The script compares the exact payload as well as the claim/lease predicates.
1551
+ for (let attempt = 0; attempt < 3; attempt++) {
1552
+ const payload = await this.client.hGet(this.lettersKey, task.uuid);
1553
+ if (!payload) return false;
1554
+ const current = normalizePolicyArrays(JSON.parse(payload));
1555
+ const persist = stripInternalUpdateMeta(updateObj);
1556
+ const next = isAppend
1557
+ ? { ...current, mailOptions: [...(current.mailOptions || []), updateObj.appendMailOption] }
1558
+ : { ...current, ...persist };
1559
+ if (isAppend) {
1560
+ const result = Number(await this.__runScript('append', {
1561
+ keys: [this.lettersKey],
1562
+ arguments: [task.uuid, JSON.stringify(updateObj.appendMailOption), payload, JSON.stringify(next)],
1563
+ }));
1564
+ if (result === -1) continue;
1565
+ return result >= 1;
1566
+ }
1567
+ const keys = [this.lettersKey, this.scheduleKey];
1568
+ if (current.to) keys.push(this.__getTaggedConcatKey(current.to), this.concatKeysKey);
1569
+ const result = Number(await this.__runScript('update', {
1570
+ keys,
1571
+ arguments: [task.uuid, JSON.stringify(persist), isClaim ? 'claim' : (isLeaseRelease ? 'lease' : 'plain'),
1572
+ `${now}`, `${sendingTimeout}`, `${isClaim ? task.tries : (updateObj.leaseTries || 0)}`,
1573
+ `${updateObj.leaseSendingAt || 0}`, payload, JSON.stringify(next)],
1574
+ }));
1575
+ if (result !== -1) return result >= 1;
1576
+ }
1577
+ return false;
1578
+ }
1579
+
1164
1580
  if (isAppend) {
1165
1581
  if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
1166
1582
  if (!RedisQueue.__atomicAppendWarned) {
@@ -1172,7 +1588,7 @@ class RedisQueue {
1172
1588
  return false;
1173
1589
  }
1174
1590
  const currentTask = JSON.parse(taskJSON);
1175
- if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
1591
+ if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true || currentTask.isSettled === true || Array.isArray(currentTask.recipientResults)) {
1176
1592
  return false;
1177
1593
  }
1178
1594
  currentTask.mailOptions = [...(currentTask.mailOptions || []), updateObj.appendMailOption];
@@ -1187,7 +1603,7 @@ class RedisQueue {
1187
1603
  return false;
1188
1604
  }
1189
1605
  const currentTask = JSON.parse(taskJSON);
1190
- if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
1606
+ if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true || currentTask.isSettled === true || Array.isArray(currentTask.recipientResults)) {
1191
1607
  await this.client.unwatch?.();
1192
1608
  return false;
1193
1609
  }
@@ -1227,7 +1643,7 @@ class RedisQueue {
1227
1643
  const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
1228
1644
  const multi = this.client.multi();
1229
1645
  multi.set(letterKey, JSON.stringify(updatedTask));
1230
- if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
1646
+ if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true || updatedTask.isSettled === true) {
1231
1647
  multi.del(sendatKey);
1232
1648
  } else if (updatedTask.sendAt) {
1233
1649
  multi.set(sendatKey, `${+updatedTask.sendAt}`);
@@ -1246,7 +1662,7 @@ class RedisQueue {
1246
1662
  const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
1247
1663
  await this.client.set(letterKey, JSON.stringify(updatedTask));
1248
1664
 
1249
- if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
1665
+ if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true || updatedTask.isSettled === true) {
1250
1666
  await this.client.del(sendatKey);
1251
1667
  } else if (updatedTask.sendAt) {
1252
1668
  await this.client.set(sendatKey, `${+updatedTask.sendAt}`);
@@ -1272,6 +1688,15 @@ class RedisQueue {
1272
1688
  throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
1273
1689
  }
1274
1690
  this.__ensurePrefix();
1691
+ if (this.useHashTags) {
1692
+ if (type === 'letter') {
1693
+ return this.lettersKey;
1694
+ }
1695
+ if (type === 'sendat') {
1696
+ return this.scheduleKey;
1697
+ }
1698
+ return this.__getTaggedConcatKey(uuid);
1699
+ }
1275
1700
  return `${this.uniqueName}:${type}:${uuid}`;
1276
1701
  }
1277
1702
  }
@@ -1315,6 +1740,8 @@ const fieldMap = {
1315
1740
  tries: 'tries',
1316
1741
  sendAt: 'send_at',
1317
1742
  isSent: 'is_sent',
1743
+ isSettled: 'is_settled',
1744
+ recipientResults: 'recipient_results',
1318
1745
  isCancelled: 'is_cancelled',
1319
1746
  isFailed: 'is_failed',
1320
1747
  isSending: 'is_sending',
@@ -1344,6 +1771,8 @@ const normalizeRow = (row) => {
1344
1771
  tries: parseInt(row.tries, 10),
1345
1772
  sendAt: parseInt(row.send_at, 10),
1346
1773
  isSent: row.is_sent,
1774
+ isSettled: row.is_settled === true,
1775
+ recipientResults: row.recipient_results == null ? void 0 : parseMailOptions(row.recipient_results),
1347
1776
  isCancelled: row.is_cancelled,
1348
1777
  isFailed: row.is_failed,
1349
1778
  isSending: row.is_sending === true,
@@ -1363,6 +1792,7 @@ class PostgresQueue {
1363
1792
  */
1364
1793
  constructor(opts) {
1365
1794
  this.name = 'postgres-queue';
1795
+ this.supportsRecipientPolicies = true;
1366
1796
  if (!opts || typeof opts !== 'object') {
1367
1797
  throw new TypeError('[mail-time] Configuration object must be passed into PostgresQueue constructor');
1368
1798
  }
@@ -1423,6 +1853,8 @@ class PostgresQueue {
1423
1853
  tries INTEGER NOT NULL DEFAULT 0,
1424
1854
  send_at BIGINT NOT NULL,
1425
1855
  is_sent BOOLEAN NOT NULL DEFAULT false,
1856
+ is_settled BOOLEAN NOT NULL DEFAULT false,
1857
+ recipient_results JSONB,
1426
1858
  is_cancelled BOOLEAN NOT NULL DEFAULT false,
1427
1859
  is_failed BOOLEAN NOT NULL DEFAULT false,
1428
1860
  is_sending BOOLEAN NOT NULL DEFAULT false,
@@ -1434,14 +1866,16 @@ class PostgresQueue {
1434
1866
  created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
1435
1867
  updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
1436
1868
  )`);
1869
+ await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS is_settled BOOLEAN NOT NULL DEFAULT false');
1870
+ await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS recipient_results JSONB');
1437
1871
  await this.client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
1438
1872
  ON mail_time_queue (prefix, uuid)`);
1439
1873
 
1440
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_due
1441
- ON mail_time_queue (prefix, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
1874
+ await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_due_v1
1875
+ ON mail_time_queue (prefix, is_settled, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
1442
1876
 
1443
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_pending_to
1444
- ON mail_time_queue (prefix, to_address, is_sent, is_failed, is_cancelled, send_at)`);
1877
+ await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_pending_to_v1
1878
+ ON mail_time_queue (prefix, to_address, is_settled, is_sent, is_failed, is_cancelled, send_at)`);
1445
1879
  } finally {
1446
1880
  await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1447
1881
  }
@@ -1516,14 +1950,15 @@ class PostgresQueue {
1516
1950
 
1517
1951
  try {
1518
1952
  const res = await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1519
- is_sending, sending_at, template, transport, concat_subject, mail_options
1953
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results
1520
1954
  FROM mail_time_queue
1521
1955
  WHERE prefix = $1
1522
1956
  AND is_sent = false
1523
1957
  AND is_failed = false
1524
1958
  AND is_cancelled = false
1959
+ AND is_settled = false
1525
1960
  AND send_at <= $2
1526
- AND tries < $3
1961
+ AND (tries < $3 OR (recipient_results IS NOT NULL AND is_sending = true AND sending_at <= $4))
1527
1962
  AND (is_sending = false OR sending_at <= $4)
1528
1963
  ORDER BY send_at ASC
1529
1964
  LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
@@ -1554,7 +1989,7 @@ class PostgresQueue {
1554
1989
  await this.ready();
1555
1990
 
1556
1991
  const res = await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1557
- is_sending, sending_at, template, transport, concat_subject, mail_options
1992
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results
1558
1993
  FROM mail_time_queue
1559
1994
  WHERE prefix = $1
1560
1995
  AND to_address = $2
@@ -1562,6 +1997,8 @@ class PostgresQueue {
1562
1997
  AND is_failed = false
1563
1998
  AND is_cancelled = false
1564
1999
  AND is_sending = false
2000
+ AND is_settled = false
2001
+ AND recipient_results IS NULL
1565
2002
  AND tries < $4
1566
2003
  AND send_at <= $3
1567
2004
  ORDER BY send_at DESC
@@ -1592,9 +2029,9 @@ class PostgresQueue {
1592
2029
 
1593
2030
  await this.client.query(`INSERT INTO mail_time_queue (
1594
2031
  prefix, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1595
- is_sending, sending_at, template, transport, concat_subject, mail_options, created_at, updated_at
2032
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results, created_at, updated_at
1596
2033
  )
1597
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
2034
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
1598
2035
  ON CONFLICT (prefix, uuid) DO UPDATE SET
1599
2036
  to_address = EXCLUDED.to_address,
1600
2037
  tries = EXCLUDED.tries,
@@ -1608,6 +2045,8 @@ class PostgresQueue {
1608
2045
  transport = EXCLUDED.transport,
1609
2046
  concat_subject = EXCLUDED.concat_subject,
1610
2047
  mail_options = EXCLUDED.mail_options,
2048
+ is_settled = EXCLUDED.is_settled,
2049
+ recipient_results = EXCLUDED.recipient_results,
1611
2050
  updated_at = CURRENT_TIMESTAMP`, [
1612
2051
  this.prefix,
1613
2052
  task.uuid,
@@ -1623,6 +2062,8 @@ class PostgresQueue {
1623
2062
  task.transport,
1624
2063
  task.concatSubject || null,
1625
2064
  JSON.stringify(task.mailOptions || []),
2065
+ task.isSettled === true,
2066
+ task.recipientResults === void 0 ? null : JSON.stringify(task.recipientResults),
1626
2067
  ]);
1627
2068
  }
1628
2069
 
@@ -1643,23 +2084,20 @@ class PostgresQueue {
1643
2084
  await this.ready();
1644
2085
 
1645
2086
  const task = normalizeRow((await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1646
- template, transport, concat_subject, mail_options
2087
+ template, transport, concat_subject, mail_options, is_settled, recipient_results
1647
2088
  FROM mail_time_queue
1648
2089
  WHERE prefix = $1
1649
2090
  AND uuid = $2
1650
2091
  LIMIT 1`, [this.prefix, uuid])).rows?.[0]);
1651
2092
 
1652
- if (!task || task.isSent === true || task.isCancelled === true) {
1653
- return false;
1654
- }
1655
-
1656
- if (!this.mailTimeInstance.keepHistory) {
1657
- return await this.remove(task);
1658
- }
1659
-
1660
- return await this.update(task, {
1661
- isCancelled: true,
1662
- });
2093
+ if (!task || task.isSent === true || task.isCancelled === true || task.isSettled === true) return false;
2094
+ const operation = this.mailTimeInstance.keepHistory
2095
+ ? 'UPDATE mail_time_queue SET is_cancelled = true, updated_at = CURRENT_TIMESTAMP'
2096
+ : 'DELETE FROM mail_time_queue';
2097
+ const result = await this.client.query(`${operation}
2098
+ WHERE prefix = $1 AND uuid = $2 AND is_sent = false AND is_cancelled = false
2099
+ AND is_settled = false AND (recipient_results IS NULL OR is_failed = false)`, [this.prefix, uuid]);
2100
+ return (result.rowCount || 0) >= 1;
1663
2101
  }
1664
2102
 
1665
2103
  /**
@@ -1685,7 +2123,7 @@ class PostgresQueue {
1685
2123
  const params = [this.prefix, value];
1686
2124
  if (isSendLeaseRemove(opts)) {
1687
2125
  params.push(opts.leaseTries, opts.leaseSendingAt);
1688
- leaseWhere = ` AND tries = $3 AND is_sending = true AND sending_at = $4 AND is_cancelled = false AND is_failed = false`;
2126
+ 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`;
1689
2127
  }
1690
2128
 
1691
2129
  const res = await this.client.query(`DELETE FROM mail_time_queue
@@ -1710,6 +2148,8 @@ class PostgresQueue {
1710
2148
  }
1711
2149
 
1712
2150
  await this.ready();
2151
+ if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled'))
2152
+ && !isSendClaimUpdate(updateObj) && !isSendLeaseGuardedUpdate(updateObj)) return false;
1713
2153
 
1714
2154
  if (isAppendMailOptionUpdate(updateObj)) {
1715
2155
  const where = task.id ? 'id = $3' : 'uuid = $3';
@@ -1722,7 +2162,9 @@ class PostgresQueue {
1722
2162
  AND is_sent = false
1723
2163
  AND is_failed = false
1724
2164
  AND is_cancelled = false
1725
- AND is_sending = false`, [
2165
+ AND is_sending = false
2166
+ AND is_settled = false
2167
+ AND recipient_results IS NULL`, [
1726
2168
  JSON.stringify([updateObj.appendMailOption]),
1727
2169
  this.prefix,
1728
2170
  value,
@@ -1742,7 +2184,7 @@ class PostgresQueue {
1742
2184
  if (key === 'sendAt' && value instanceof Date) {
1743
2185
  value = +value;
1744
2186
  }
1745
- if (key === 'mailOptions') {
2187
+ if (key === 'mailOptions' || key === 'recipientResults') {
1746
2188
  value = JSON.stringify(value);
1747
2189
  }
1748
2190
 
@@ -1766,6 +2208,7 @@ class PostgresQueue {
1766
2208
  AND is_sent = false
1767
2209
  AND is_failed = false
1768
2210
  AND is_cancelled = false
2211
+ AND is_settled = false
1769
2212
  AND tries = $${triesIndex}
1770
2213
  AND (is_sending = false OR sending_at <= $${staleIndex})
1771
2214
  `;
@@ -1775,6 +2218,8 @@ class PostgresQueue {
1775
2218
  values.push(updateObj.leaseSendingAt);
1776
2219
  const sendingAtIndex = values.length;
1777
2220
  claimWhere = `
2221
+ AND is_sent = false
2222
+ AND is_settled = false
1778
2223
  AND tries = $${triesIndex}
1779
2224
  AND is_sending = true
1780
2225
  AND sending_at = $${sendingAtIndex}
@@ -1990,6 +2435,316 @@ const mailTimePreset = (name, overrides) => {
1990
2435
  return overrides ? deepMerge(cloned, overrides) : cloned;
1991
2436
  };
1992
2437
 
2438
+ const policyError = (message) => new Error(`[mail-time] [recipientPolicies] ${message}`);
2439
+ const SIMPLE_MAILBOX = /^[^\s<>,;:"()\\\[\]@]+@[^\s<>,;:"()\\\[\]@]+$/u;
2440
+
2441
+ const normalizePolicyAddress = (value) => {
2442
+ const input = typeof value === 'string' ? value : (!Array.isArray(value) && value?.address);
2443
+ if (typeof input !== 'string' || /[\r\n]/u.test(input)) {
2444
+ throw policyError('recipient requires a simple address without line breaks');
2445
+ }
2446
+ let address = input.trim();
2447
+ if (address.includes('<') || address.includes('>')) {
2448
+ const match = address.match(/^[^<>,;:"\r\n]*<([^<>]+)>$/u);
2449
+ if (!match) throw policyError('use a simple explicit envelope.to');
2450
+ address = match[1].trim();
2451
+ }
2452
+ if (!SIMPLE_MAILBOX.test(address)) throw policyError('use a simple explicit envelope.to');
2453
+ return address.toLowerCase();
2454
+ };
2455
+
2456
+ const preparePolicyEnvelope = (compiled, previousResults = []) => {
2457
+ const recipients = new Map();
2458
+ const explicit = compiled.envelope && hasOwnProp(compiled.envelope, 'to');
2459
+ const add = (value, source, authoritative) => {
2460
+ for (const entry of Array.isArray(value) ? value : [value]) {
2461
+ let address;
2462
+ try { address = normalizePolicyAddress(entry); }
2463
+ catch (error) { if (authoritative) throw error; else continue; }
2464
+ if (!recipients.has(address)) {
2465
+ if (!authoritative) continue;
2466
+ recipients.set(address, { address, sources: [] });
2467
+ }
2468
+ const sources = recipients.get(address).sources;
2469
+ if (!sources.includes(source)) sources.push(source);
2470
+ }
2471
+ };
2472
+ if (explicit) add(compiled.envelope.to, 'envelope', true);
2473
+ for (const source of ['to', 'cc', 'bcc']) {
2474
+ if (hasOwnProp(compiled, source) && compiled[source] !== void 0) add(compiled[source], source, !explicit);
2475
+ }
2476
+ if (!recipients.size) throw policyError('envelope must contain at least one recipient');
2477
+ if (previousResults.length && (previousResults.length !== recipients.size || previousResults.some((r) => !recipients.has(r.address)))) {
2478
+ throw policyError('envelope recipient set changed between attempts');
2479
+ }
2480
+ const envelope = { to: [...recipients.keys()] };
2481
+ if (compiled.envelope && hasOwnProp(compiled.envelope, 'from')) {
2482
+ envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from);
2483
+ } else {
2484
+ const from = compiled.from || compiled.sender || compiled.replyTo;
2485
+ if (from) envelope.from = normalizePolicyAddress(from);
2486
+ }
2487
+ return { envelope, recipients: [...recipients.values()] };
2488
+ };
2489
+
2490
+ const validateRecipientPolicies = (value, queue) => {
2491
+ if (value === void 0) return null;
2492
+ if (!Array.isArray(value) || !value.length) throw policyError('recipientPolicies must be a nonempty array');
2493
+ if (queue.supportsRecipientPolicies !== true) throw policyError('queue must declare recipient policy support');
2494
+ const names = new Set();
2495
+ return value.map((provider) => {
2496
+ if (!isPlainObject(provider)) throw policyError('each provider must be a plain object');
2497
+ const name = typeof provider.name === 'string' ? provider.name.trim() : '';
2498
+ if (!name || name.length > 128 || names.has(name)) throw policyError('provider names must be unique and contain 1-128 characters');
2499
+ names.add(name);
2500
+ const failureMode = provider.failureMode === void 0 ? 'retry' : provider.failureMode;
2501
+ if (failureMode !== 'retry' && failureMode !== 'continue') throw policyError('failureMode must be retry or continue');
2502
+ const normalized = { name, failureMode };
2503
+ let count = 0;
2504
+ for (const hook of ['beforeSend', 'classifyRejections', 'observeAttempt']) {
2505
+ if (hasOwnProp(provider, hook)) {
2506
+ if (typeof provider[hook] !== 'function') throw policyError(`${hook} must be a function`);
2507
+ normalized[hook] = provider[hook].bind(provider);
2508
+ count++;
2509
+ }
2510
+ }
2511
+ if (!count) throw policyError('provider requires at least one supported hook');
2512
+ return normalized;
2513
+ });
2514
+ };
2515
+
2516
+ const validatePolicyResult = (raw, hook, context) => {
2517
+ if (raw === void 0) return [];
2518
+ if (!isPlainObject(raw)) throw policyError('hook must return a result object or nothing');
2519
+ if (raw.decisions === void 0) return [];
2520
+ if (!Array.isArray(raw.decisions)) throw policyError('decisions must be an array');
2521
+ const batch = new Set(context.recipients.map((r) => r.address));
2522
+ const attributable = new Set((context.rejections || []).map((r) => r.address).filter(Boolean));
2523
+ const decisions = new Map();
2524
+ for (const item of raw.decisions) {
2525
+ if (!isPlainObject(item)) throw policyError('decision must be an object');
2526
+ const address = normalizePolicyAddress(item.address);
2527
+ if (!batch.has(address)) throw policyError('decision address is outside the input batch');
2528
+ const allowed = hook === 'beforeSend' ? item.status === 'suppressed' : (item.status === 'retry' || item.status === 'rejected');
2529
+ if (!allowed) throw policyError('invalid decision status for this phase');
2530
+ if (item.status === 'rejected' && !attributable.has(address)) throw policyError('permanent rejection requires an attributable record');
2531
+ if (typeof item.reason !== 'string' || !item.reason.trim() || item.reason.length > 512) throw policyError('reason must contain 1-512 characters');
2532
+ const previous = decisions.get(address);
2533
+ if (previous && (previous.status !== item.status || previous.reason !== item.reason)) throw policyError('conflicting duplicate decisions');
2534
+ decisions.set(address, { address, status: item.status, reason: item.reason });
2535
+ }
2536
+ return [...decisions.values()];
2537
+ };
2538
+
2539
+ const evaluatePolicyPhase = async (providers, hook, context, report) => {
2540
+ const decisions = [];
2541
+ let retryFailure = false;
2542
+ for (const provider of providers) {
2543
+ if (typeof provider[hook] !== 'function') continue;
2544
+ try {
2545
+ const validated = validatePolicyResult(await provider[hook](context), hook, context);
2546
+ for (const decision of validated) decisions.push({ ...decision, provider: provider.name });
2547
+ } catch (error) {
2548
+ report(error, provider.name, hook);
2549
+ if (provider.failureMode !== 'continue') retryFailure = true;
2550
+ }
2551
+ }
2552
+ return { retryFailure, decisions };
2553
+ };
2554
+
2555
+ const mergePolicyResults = (previous, recipients, evaluated, details) => {
2556
+ const results = new Map(previous.map((r) => [r.address, r]));
2557
+ const accepted = new Set(details.accepted || []);
2558
+ const decisions = new Map();
2559
+ for (const decision of evaluated.decisions) {
2560
+ if (!decisions.has(decision.address)) decisions.set(decision.address, []);
2561
+ decisions.get(decision.address).push(decision);
2562
+ }
2563
+ const diagnostics = new Map();
2564
+ for (const record of details.rejections || []) {
2565
+ if (record.address && !diagnostics.has(record.address)) diagnostics.set(record.address, record);
2566
+ }
2567
+ for (const recipient of recipients) {
2568
+ const prior = results.get(recipient.address);
2569
+ if (prior && prior.status !== 'error') continue;
2570
+ const candidates = decisions.get(recipient.address) || [];
2571
+ const terminal = details.phase === 'beforeSend' ? 'suppressed' : 'rejected';
2572
+ let status = 'error';
2573
+ if (accepted.has(recipient.address)) status = 'sent';
2574
+ else if (!evaluated.retryFailure && candidates.some((d) => d.status === terminal)) status = terminal;
2575
+ const reasons = [];
2576
+ if (!evaluated.retryFailure && status !== 'sent') {
2577
+ for (const candidate of candidates) {
2578
+ if (candidate.status === (status === 'error' ? 'retry' : status)) reasons.push({ provider: candidate.provider, reason: candidate.reason });
2579
+ }
2580
+ }
2581
+ const result = { address: recipient.address, status, sources: [...recipient.sources], reasons, attempt: details.attempt, transportIndex: details.transportIndex };
2582
+ if (typeof details.transportName === 'string') result.transportName = details.transportName.slice(0, 128);
2583
+ const record = diagnostics.get(recipient.address);
2584
+ if (record) {
2585
+ for (const key of ['command', 'response', 'message']) {
2586
+ if (typeof record[key] === 'string') result[key] = record[key].slice(0, 2048);
2587
+ }
2588
+ if (Number.isFinite(record.responseCode)) result.responseCode = record.responseCode;
2589
+ }
2590
+ results.set(recipient.address, result);
2591
+ }
2592
+ return [...results.values()];
2593
+ };
2594
+
2595
+ const summarizePolicyTask = (task, isSettled) => {
2596
+ const recipients = { sent: [], error: [], suppressed: [], rejected: [] };
2597
+ for (const result of task.recipientResults || []) recipients[result.status].push(result);
2598
+ return { uuid: task.uuid, tries: task.tries, isSettled, recipients };
2599
+ };
2600
+
2601
+ const diagnosticFields = (record) => {
2602
+ const result = {};
2603
+ for (const key of ['command', 'response', 'message']) {
2604
+ if (typeof record?.[key] === 'string') result[key] = record[key].slice(0, 2048);
2605
+ }
2606
+ if (Number.isFinite(record?.responseCode)) result.responseCode = record.responseCode;
2607
+ return result;
2608
+ };
2609
+
2610
+ const normalizeRejections = (error, info, transport) => {
2611
+ const records = [];
2612
+ const ancestors = new WeakSet();
2613
+ const addressOf = (value) => {
2614
+ try { return normalizePolicyAddress(value); }
2615
+ catch { return null; }
2616
+ };
2617
+ const append = (node, address) => {
2618
+ const record = { address, ...diagnosticFields(node), transportIndex: transport.index };
2619
+ if (typeof transport.name === 'string') record.transportName = transport.name.slice(0, 128);
2620
+ if (typeof node === 'string') record.message = node.slice(0, 2048);
2621
+ records.push(record);
2622
+ };
2623
+ const visit = (node, positional = null, root = false, errorRoot = false) => {
2624
+ if (node === null || node === void 0) {
2625
+ if (!root) append(node, positional);
2626
+ return;
2627
+ }
2628
+ if (typeof node !== 'object') {
2629
+ append(node, positional);
2630
+ return;
2631
+ }
2632
+ if (ancestors.has(node)) return;
2633
+ ancestors.add(node);
2634
+ const start = records.length;
2635
+ const rejected = Array.isArray(node.rejected) ? node.rejected.map(addressOf) : [];
2636
+ for (const key of ['errors', 'rejectedErrors']) {
2637
+ if (Array.isArray(node[key])) {
2638
+ for (let i = 0; i < node[key].length; i++) visit(node[key][i], rejected[i] || null);
2639
+ }
2640
+ }
2641
+ if (records.length === start) {
2642
+ let address = positional;
2643
+ for (const key of ['recipient', 'address', 'to']) {
2644
+ if (hasOwnProp(node, key)) { address = addressOf(node[key]); break; }
2645
+ }
2646
+ if (!root || errorRoot || address || Object.keys(diagnosticFields(node)).length) append(node, address);
2647
+ }
2648
+ const attributed = new Set(records.slice(start).map((r) => r.address));
2649
+ for (const address of rejected) {
2650
+ if (address && !attributed.has(address)) append({ message: 'Recipient rejected by transport' }, address);
2651
+ }
2652
+ ancestors.delete(node);
2653
+ };
2654
+ visit(error, null, true, true);
2655
+ visit(info, null, true);
2656
+ return records;
2657
+ };
2658
+
2659
+ class RecipientPolicyLease {
2660
+ constructor({ task, queue, interval, maxRenewals, shouldAbort, report }) {
2661
+ this.__task = task;
2662
+ this.__queue = queue;
2663
+ this.__shouldAbort = shouldAbort;
2664
+ this.__report = report;
2665
+ this.__closed = false;
2666
+ this.__finishing = false;
2667
+ this.__tail = Promise.resolve();
2668
+ this.__timer = null;
2669
+ let pending = false;
2670
+ let renewals = 0;
2671
+ if (interval && maxRenewals) {
2672
+ this.__timer = setInterval(() => {
2673
+ if (!this.active || this.__finishing || renewals >= maxRenewals) {
2674
+ this.__clearTimer();
2675
+ return;
2676
+ }
2677
+ if (pending) return;
2678
+ pending = true;
2679
+ renewals++;
2680
+ this.__enqueue(async (guard) => {
2681
+ const fields = { isSending: true, sendingAt: Math.max(Date.now(), this.__task.sendingAt + 1) };
2682
+ return await this.__write(fields, guard);
2683
+ }).finally(() => { pending = false; });
2684
+ }, interval);
2685
+ this.__timer.unref?.();
2686
+ }
2687
+ }
2688
+
2689
+ get active() { return !this.__closed && !this.__shouldAbort(); }
2690
+
2691
+ __clearTimer() {
2692
+ if (this.__timer) clearInterval(this.__timer);
2693
+ this.__timer = null;
2694
+ }
2695
+
2696
+ __halt() {
2697
+ this.__clearTimer();
2698
+ this.__closed = true;
2699
+ }
2700
+
2701
+ __enqueue(operation) {
2702
+ const pending = this.__tail.then(async () => {
2703
+ if (!this.active) return false;
2704
+ const guard = { leaseTries: this.__task.tries, leaseSendingAt: this.__task.sendingAt };
2705
+ try {
2706
+ const ok = await operation(guard);
2707
+ if (!ok) this.__halt();
2708
+ return ok;
2709
+ } catch (error) {
2710
+ this.__halt();
2711
+ if (!this.__shouldAbort()) this.__report(error);
2712
+ return false;
2713
+ }
2714
+ });
2715
+ this.__tail = pending.then(() => void 0);
2716
+ return pending;
2717
+ }
2718
+
2719
+ async __write(fields, guard) {
2720
+ const ok = await this.__queue.update(this.__task, { ...fields, ...guard });
2721
+ if (ok) Object.assign(this.__task, fields);
2722
+ return ok;
2723
+ }
2724
+
2725
+ update(fields) {
2726
+ if (this.__finishing) return Promise.resolve(false);
2727
+ return this.__enqueue((guard) => this.__write(fields, guard));
2728
+ }
2729
+
2730
+ finish(fields, remove) {
2731
+ if (this.__finishing) return Promise.resolve(false);
2732
+ this.__finishing = true;
2733
+ this.__clearTimer();
2734
+ return this.__enqueue(async (guard) => {
2735
+ const ok = remove ? await this.__queue.remove(this.__task, guard) : await this.__queue.update(this.__task, { ...fields, ...guard });
2736
+ if (ok) Object.assign(this.__task, fields);
2737
+ this.__halt();
2738
+ return ok;
2739
+ });
2740
+ }
2741
+
2742
+ async stop() {
2743
+ this.__halt();
2744
+ await this.__tail;
2745
+ }
2746
+ }
2747
+
1993
2748
  const noop = () => {};
1994
2749
  const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
1995
2750
 
@@ -2191,11 +2946,32 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2191
2946
  */
2192
2947
 
2193
2948
  /**
2194
- * @typedef {{ [key: string]: any, ping: () => Promise<MailTimePingResult>, setInterval: (func: (...args: any[]) => unknown, delay: number, uid: string) => Promise<string>, destroy: () => boolean, pause: (timerId?: string) => boolean, resume: (timerId?: string) => boolean }} MailTimeScheduler
2949
+ * @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
2950
+ */
2951
+
2952
+ /**
2953
+ * @typedef {string | { address: string, name?: string }} MailTimeMailbox
2195
2954
  */
2196
2955
 
2197
2956
  /**
2198
- * @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
2957
+ * @typedef {{ address: string, sources: Array<'envelope' | 'to' | 'cc' | 'bcc'> }} MailTimePolicyRecipient
2958
+ * @typedef {{ address: string, status: 'suppressed' | 'rejected' | 'retry', reason: string }} MailTimePolicyDecision
2959
+ * @typedef {{ decisions?: MailTimePolicyDecision[] }} MailTimePolicyResult
2960
+ * @typedef {{ index: number, name?: string }} MailTimePolicyTransport
2961
+ * @typedef {{ from?: string, to: string[] }} MailTimePolicyEnvelope
2962
+ * @typedef {{ task: MailTimeTask, attempt: number, transport: MailTimePolicyTransport, envelope: MailTimePolicyEnvelope, recipients: MailTimePolicyRecipient[] }} MailTimePolicyContext
2963
+ * @typedef {MailTimePolicyContext} MailTimeBeforeSendPolicyContext
2964
+ * @typedef {{ address: string | null, command?: string, responseCode?: number, response?: string, message?: string, transportIndex: number, transportName?: string }} MailTimeStructuredRejection
2965
+ * @typedef {MailTimePolicyContext & { error?: unknown, info?: unknown, rejections: MailTimeStructuredRejection[] }} MailTimeRejectionPolicyContext
2966
+ * @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
2967
+ * @typedef {MailTimeRejectionPolicyContext & { decisions: MailTimeRecipientResult[] }} MailTimeRecipientAttemptContext
2968
+ * @typedef {{ uuid: string, tries: number, isSettled: boolean, recipients: { sent: MailTimeRecipientResult[], error: MailTimeRecipientResult[], suppressed: MailTimeRecipientResult[], rejected: MailTimeRecipientResult[] } }} MailTimeRecipientSummary
2969
+ * @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
2970
+ * @description Policy contexts are read-only by contract. Providers must bound their own I/O; renewal budgets do not limit hook duration.
2971
+ */
2972
+
2973
+ /**
2974
+ * @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
2199
2975
  */
2200
2976
 
2201
2977
  /**
@@ -2203,7 +2979,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2203
2979
  */
2204
2980
 
2205
2981
  /**
2206
- * @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
2982
+ * @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
2207
2983
  */
2208
2984
 
2209
2985
  /**
@@ -2211,7 +2987,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2211
2987
  */
2212
2988
 
2213
2989
  /**
2214
- * @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
2990
+ * @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
2215
2991
  */
2216
2992
 
2217
2993
  /**
@@ -2223,10 +2999,16 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2223
2999
  */
2224
3000
 
2225
3001
  /**
2226
- * @typedef {{ queue: RedisQueue | MongoQueue | PostgresQueue | CustomQueue, type?: 'server' | 'client', from?: string | ((transport: MailTimeTransport, details: MailTimeFromDetails) => string), transports?: MailTimeTransport[], strategy?: 'backup' | 'balancer', failsToNext?: number, shouldFailOver?: (error: unknown, info: object | undefined, email: MailTimeTask) => boolean, retries?: number, maxTries?: number, retryDelay?: number, interval?: number, keepHistory?: boolean, concatEmails?: boolean | MailTimeConcatEmailsOptions, concatSubject?: string, concatDelimiter?: string, concatDelay?: number, concatThrottling?: number, revolvingInterval?: number, mode?: 'one' | 'batch', concurrency?: number, sendingTimeout?: number, renewClaim?: boolean | number, maxRenewals?: number, strictPayload?: boolean, allowedMailFields?: string[], verifyTransports?: boolean, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, onError?: (error: unknown, email: MailTimeTask | null, details?: object) => void, onSent?: (email: MailTimeTask, details?: object) => void }} MailTimeOptions
3002
+ * @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
2227
3003
  */
2228
3004
 
2229
- /** Class of MailTime */
3005
+ /**
3006
+ * Class of MailTime.
3007
+ * With recipientPolicies, terminal callbacks receive grouped recipients and a complete summary after persistence.
3008
+ * isSettled includes exhausted errors; isSent means all accepted; isFailed means an error or permanent rejection.
3009
+ * Unparseable terminal preparation failures invoke onError with an empty recipient group.
3010
+ * Hooks must bound their own I/O. Callback notifications are best-effort, not durable exactly-once delivery.
3011
+ */
2230
3012
  class MailTime {
2231
3013
  /**
2232
3014
  * Create a MailTime instance
@@ -2241,6 +3023,7 @@ class MailTime {
2241
3023
  throw new Error('[mail-time] {queue} option is required: provide a MongoQueue, RedisQueue, PostgresQueue, or CustomQueue instance');
2242
3024
  }
2243
3025
 
3026
+ this.__recipientPolicies = validateRecipientPolicies(opts.recipientPolicies, opts.queue);
2244
3027
  this.queue = opts.queue;
2245
3028
 
2246
3029
  for (let i = queueMethods.length - 1; i >= 0; i--) {
@@ -2280,6 +3063,8 @@ class MailTime {
2280
3063
  this.keepHistory = opts.keepHistory === true;
2281
3064
  this.onSent = (typeof opts.onSent === 'function') ? opts.onSent.bind(this) : noop;
2282
3065
  this.onError = (typeof opts.onError === 'function') ? opts.onError.bind(this) : noop;
3066
+ this.onSuppressed = (typeof opts.onSuppressed === 'function') ? opts.onSuppressed.bind(this) : noop;
3067
+ this.onRejected = (typeof opts.onRejected === 'function') ? opts.onRejected.bind(this) : noop;
2283
3068
 
2284
3069
  this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
2285
3070
  this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
@@ -2492,8 +3277,8 @@ class MailTime {
2492
3277
  /**
2493
3278
  * @memberOf MailTime
2494
3279
  * @name destroy
2495
- * @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, any in-flight SMTP attempts are neutralized on completion — they perform no storage writes, no `onSent`/`onError` callbacks, and no logging once `destroy()` returns; their claimed rows are recovered by stale-lock timeout (`sendingTimeout`). Pass `{ drain: true }` to instead let in-flight attempts run to completion (returns a Promise that resolves once they settle).
2496
- * @param {{ drain?: boolean }} [opts]
3280
+ * @description Stop the scheduler and block future dispatches. 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 a scheduler handler exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. The timeout does not bound SMTP drain time.
3281
+ * @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
2497
3282
  * @returns {boolean | Promise<boolean>}
2498
3283
  */
2499
3284
  destroy(opts) {
@@ -2501,14 +3286,29 @@ class MailTime {
2501
3286
  if (this.__isDestroyed) {
2502
3287
  return false;
2503
3288
  }
3289
+ if (opts?.drain === true && opts.schedulerTimeout !== void 0
3290
+ && (typeof opts.schedulerTimeout !== 'number' || !Number.isFinite(opts.schedulerTimeout) || opts.schedulerTimeout < 0)) {
3291
+ throw new Error('[mail-time] [destroy] schedulerTimeout must be a finite non-negative number');
3292
+ }
2504
3293
 
2505
3294
  this.__isDestroyed = true;
2506
3295
  this.__isPaused = false;
2507
- if (this.scheduler && typeof this.scheduler.destroy === 'function') {
2508
- this.scheduler.destroy();
3296
+ if (opts?.drain === true) {
3297
+ return (async () => {
3298
+ let finished = true;
3299
+ try {
3300
+ if (this.scheduler) finished = await this.scheduler.shutdown({ timeout: opts.schedulerTimeout });
3301
+ } catch (error) {
3302
+ logError('[destroy] scheduler shutdown failed', error);
3303
+ finished = false;
3304
+ } finally {
3305
+ await this.__pool.drain();
3306
+ }
3307
+ return finished;
3308
+ })();
2509
3309
  }
2510
- if (opts && opts.drain === true && this.__pool) {
2511
- return this.__pool.drain().then(() => true);
3310
+ if (this.scheduler) {
3311
+ this.scheduler.destroy();
2512
3312
  }
2513
3313
  // No graceful drain requested: neutralize in-flight SMTP completions so a
2514
3314
  // destroyed instance performs no storage writes, no `onSent`/`onError`
@@ -2629,7 +3429,8 @@ class MailTime {
2629
3429
  delete mailOptions.template;
2630
3430
  delete mailOptions.concatSubject;
2631
3431
 
2632
- if (typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
3432
+ const isMailbox = isPlainObject(mailOptions.to) && typeof mailOptions.to.address === 'string' && mailOptions.to.address.trim().length > 0;
3433
+ if (!isMailbox && typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
2633
3434
  throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
2634
3435
  }
2635
3436
 
@@ -2789,6 +3590,7 @@ class MailTime {
2789
3590
  uuid: crypto.randomUUID(),
2790
3591
  tries: 0,
2791
3592
  isSent: false,
3593
+ isSettled: false,
2792
3594
  sendAt: opts.sendAt,
2793
3595
  isFailed: false,
2794
3596
  isSending: false,
@@ -2922,7 +3724,7 @@ class MailTime {
2922
3724
  }
2923
3725
 
2924
3726
  const acceptedSet = collectAcceptedSet(task);
2925
- if (acceptedSet.size > 0) {
3727
+ if (!this.__recipientPolicies && acceptedSet.size > 0) {
2926
3728
  compiledOpts.to = filterAddressField(compiledOpts.to, acceptedSet);
2927
3729
  compiledOpts.cc = filterAddressField(compiledOpts.cc, acceptedSet);
2928
3730
  compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
@@ -3135,7 +3937,7 @@ class MailTime {
3135
3937
  };
3136
3938
 
3137
3939
  const renew = async () => {
3138
- if (stopped || this.__isDestroyed || this.__abortInFlight) {
3940
+ if (stopped || this.__abortInFlight) {
3139
3941
  halt();
3140
3942
  return;
3141
3943
  }
@@ -3188,6 +3990,204 @@ class MailTime {
3188
3990
  return { stop };
3189
3991
  }
3190
3992
 
3993
+ /** @internal Serialize every policy checkpoint with this attempt's claim renewal. */
3994
+ async ___sendWithRecipientPolicies(task) {
3995
+ if (!task || task.isSent || task.isFailed || task.isCancelled || task.isSettled || this.__abortInFlight) return;
3996
+ const recoveryOnly = Array.isArray(task.recipientResults) && (task.tries >= this.maxTries
3997
+ || (task.recipientResults.length > 0 && task.recipientResults.every((r) => r.status === 'sent' || r.status === 'suppressed' || r.status === 'rejected')));
3998
+ const fields = { tries: recoveryOnly ? task.tries : task.tries + 1, isSending: true, sendingAt: Math.max(Date.now(), (task.sendingAt || 0) + 1), recipientResults: task.recipientResults || [] };
3999
+ try {
4000
+ if (!await this.queue.update(task, fields)) return;
4001
+ } catch (error) {
4002
+ if (!this.__abortInFlight) logError('[recipientPolicies] claim failed', error);
4003
+ return;
4004
+ }
4005
+ Object.assign(task, fields);
4006
+ const lease = new RecipientPolicyLease({
4007
+ task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals,
4008
+ shouldAbort: () => this.__abortInFlight,
4009
+ report: (error) => logError('[recipientPolicies] lease persistence failed', error),
4010
+ });
4011
+ try {
4012
+ if (!lease.active) return;
4013
+ if (recoveryOnly) {
4014
+ let preparationFailure = false;
4015
+ if (!task.recipientResults.length) {
4016
+ try {
4017
+ const compiled = this.___compileMailOpts(this.transports[task.transport], task);
4018
+ const prepared = preparePolicyEnvelope(compiled);
4019
+ task.recipientResults = mergePolicyResults([], prepared.recipients, { retryFailure: false, decisions: [] }, {
4020
+ phase: 'beforeSend', attempt: task.tries, transportIndex: task.transport, accepted: [...collectAcceptedSet(task)],
4021
+ });
4022
+ } catch { preparationFailure = true; }
4023
+ }
4024
+ await this.___completePolicyTask(task, lease, policyError('last attempt interrupted; completing durable recipient state'), void 0, preparationFailure);
4025
+ return;
4026
+ }
4027
+ if (!this.___isHealthyTransport(task.transport)) task.transport = this.___nextHealthyTransport(task.transport);
4028
+ const transport = this.transports[task.transport];
4029
+ const compiled = this.___compileMailOpts(transport, task);
4030
+ if (!compiled.from) compiled.from = MailTime.transportFrom(transport);
4031
+ const prepared = preparePolicyEnvelope(compiled, task.recipientResults);
4032
+ const transportName = typeof transport.name === 'string' ? transport.name : transport.options?.name;
4033
+ const details = { phase: 'beforeSend', attempt: task.tries, transportIndex: task.transport, transportName, accepted: [...collectAcceptedSet(task)] };
4034
+ task.recipientResults = mergePolicyResults(task.recipientResults, prepared.recipients, { retryFailure: false, decisions: [] }, details);
4035
+ const pending = new Set(task.recipientResults.filter((r) => r.status === 'error').map((r) => r.address));
4036
+ const context = {
4037
+ task, attempt: task.tries, transport: { index: task.transport, ...(typeof transportName === 'string' ? { name: transportName.slice(0, 128) } : {}) },
4038
+ envelope: { ...prepared.envelope, to: prepared.envelope.to.filter((a) => pending.has(a)) },
4039
+ recipients: prepared.recipients.filter((r) => pending.has(r.address)),
4040
+ };
4041
+ if (!pending.size) {
4042
+ await this.___completePolicyTask(task, lease);
4043
+ return;
4044
+ }
4045
+ const evaluated = await evaluatePolicyPhase(this.__recipientPolicies, 'beforeSend', context, (error, name, hook) => {
4046
+ if (!this.__abortInFlight) logError(`[recipientPolicies] ${name}.${hook} failed`, error);
4047
+ });
4048
+ if (!lease.active) return;
4049
+ task.recipientResults = mergePolicyResults(task.recipientResults, context.recipients, evaluated, details);
4050
+ if (!await lease.update({ recipientResults: task.recipientResults, mailOptions: task.mailOptions })) return;
4051
+ if (!lease.active) return;
4052
+ if (evaluated.retryFailure) {
4053
+ await this.___retryPolicyTask(task, lease, policyError('beforeSend provider failed'), void 0, true);
4054
+ return;
4055
+ }
4056
+ const eligible = new Set(task.recipientResults.filter((r) => r.status === 'error').map((r) => r.address));
4057
+ if (!eligible.size) {
4058
+ await this.___completePolicyTask(task, lease);
4059
+ return;
4060
+ }
4061
+ context.envelope = { ...context.envelope, to: context.envelope.to.filter((a) => eligible.has(a)) };
4062
+ context.recipients = context.recipients.filter((r) => eligible.has(r.address));
4063
+ await this.___attemptPolicyTransport(task, lease, compiled, context);
4064
+ } catch (error) {
4065
+ if (lease.active) await this.___retryPolicyTask(task, lease, error, void 0, true, true);
4066
+ } finally {
4067
+ await lease.stop();
4068
+ }
4069
+ }
4070
+
4071
+ /** @internal Persist SMTP acceptance before awaiting rejection classification or observation. */
4072
+ async ___attemptPolicyTransport(task, lease, compiled, context) {
4073
+ const transport = this.transports[context.transport.index];
4074
+ const outgoing = { ...compiled, envelope: context.envelope };
4075
+ const { error, info } = await new Promise((resolve) => {
4076
+ let called = false;
4077
+ const done = (error, info) => {
4078
+ if (called) return;
4079
+ called = true;
4080
+ resolve({ error, info });
4081
+ };
4082
+ try { transport.sendMail(outgoing, done); }
4083
+ catch (error) { done(error, void 0); }
4084
+ });
4085
+ if (!lease.active) return;
4086
+ const attempted = new Set(context.envelope.to);
4087
+ const accepted = new Set();
4088
+ for (const source of [info, error]) {
4089
+ for (const value of Array.isArray(source?.accepted) ? source.accepted : []) {
4090
+ try {
4091
+ const address = normalizePolicyAddress(value);
4092
+ if (attempted.has(address)) accepted.add(address);
4093
+ } catch { /* Transport records outside supported address forms remain unresolved. */ }
4094
+ }
4095
+ }
4096
+ const details = { phase: 'classifyRejections', attempt: task.tries, transportIndex: context.transport.index, transportName: context.transport.name, accepted: [...accepted] };
4097
+ task.recipientResults = mergePolicyResults(task.recipientResults, context.recipients, { retryFailure: false, decisions: [] }, details);
4098
+ this.___mapPolicyMailOptions(task, false);
4099
+ if (!await lease.update({ recipientResults: task.recipientResults, mailOptions: task.mailOptions })) return;
4100
+ if (!lease.active) return;
4101
+ const unresolved = context.recipients.filter((r) => !accepted.has(r.address));
4102
+ if (unresolved.length) {
4103
+ const rejections = normalizeRejections(error, info, context.transport);
4104
+ const rejectionContext = { ...context, recipients: unresolved, error, info, rejections };
4105
+ const evaluated = await evaluatePolicyPhase(this.__recipientPolicies, 'classifyRejections', rejectionContext, (hookError, name, hook) => {
4106
+ if (!this.__abortInFlight) logError(`[recipientPolicies] ${name}.${hook} failed`, hookError);
4107
+ });
4108
+ if (!lease.active) return;
4109
+ task.recipientResults = mergePolicyResults(task.recipientResults, unresolved, evaluated, { ...details, rejections });
4110
+ const observation = { ...rejectionContext, decisions: task.recipientResults };
4111
+ for (const provider of this.__recipientPolicies) {
4112
+ if (!lease.active) return;
4113
+ if (typeof provider.observeAttempt !== 'function') continue;
4114
+ try { await provider.observeAttempt(observation); }
4115
+ catch (hookError) {
4116
+ if (!this.__abortInFlight) logError(`[recipientPolicies] ${provider.name}.observeAttempt failed`, hookError);
4117
+ }
4118
+ }
4119
+ }
4120
+ if (!lease.active) return;
4121
+ if (task.recipientResults.some((r) => r.status === 'error')) {
4122
+ await this.___retryPolicyTask(task, lease, error || policyError('recipients remain unaccepted'), info, !!error || accepted.size === 0);
4123
+ } else {
4124
+ await this.___completePolicyTask(task, lease, error, info);
4125
+ }
4126
+ }
4127
+
4128
+ /** @internal Keep legacy per-mail-option state limited to actual envelope recipients. */
4129
+ ___mapPolicyMailOptions(task, terminal) {
4130
+ const results = new Map(task.recipientResults.map((r) => [r.address, r]));
4131
+ for (const option of task.mailOptions || []) {
4132
+ const addresses = new Set();
4133
+ for (const field of ['to', 'cc', 'bcc']) {
4134
+ const values = Array.isArray(option[field]) ? option[field] : [option[field]];
4135
+ for (const value of values) {
4136
+ try { addresses.add(normalizePolicyAddress(value)); } catch { /* Explicit envelopes permit complex headers. */ }
4137
+ }
4138
+ }
4139
+ const accepted = new Set(Array.isArray(option.accepted) ? option.accepted : []);
4140
+ const rejected = [];
4141
+ for (const address of addresses) {
4142
+ const result = results.get(address);
4143
+ if (result?.status === 'sent') accepted.add(address);
4144
+ if (terminal && (result?.status === 'error' || result?.status === 'rejected')) {
4145
+ rejected.push({ address, error: result.message || result.response || result.reasons[0]?.reason || 'Recipient not accepted by transport' });
4146
+ }
4147
+ }
4148
+ option.accepted = [...accepted];
4149
+ if (terminal) option.rejected = rejected;
4150
+ }
4151
+ }
4152
+
4153
+ /** @internal Release a policy claim with all durable outcomes, or settle at exhaustion. */
4154
+ async ___retryPolicyTask(task, lease, error, info, allowRotation, preparationFailure = false) {
4155
+ if (!lease.active) return false;
4156
+ if (task.tries >= this.maxTries) return await this.___completePolicyTask(task, lease, error, info, preparationFailure);
4157
+ let transport = task.transport;
4158
+ if (allowRotation && this.strategy === 'backup' && this.transports.length > 1
4159
+ && task.tries % this.failsToNext === 0 && this.___mayFailOver(error, info, task)) transport = this.___nextHealthyTransport(transport);
4160
+ return await lease.finish({ isSending: false, sendingAt: 0, sendAt: Date.now() + this.retryDelay, transport,
4161
+ recipientResults: task.recipientResults, mailOptions: task.mailOptions }, false);
4162
+ }
4163
+
4164
+ /** @internal Settle truthfully before invoking any best-effort terminal notification. */
4165
+ async ___completePolicyTask(task, lease, error, info, preparationFailure = false) {
4166
+ if (!lease.active) return false;
4167
+ this.___mapPolicyMailOptions(task, true);
4168
+ const results = task.recipientResults;
4169
+ const fields = {
4170
+ isSettled: true, isSent: results.length > 0 && results.every((r) => r.status === 'sent'),
4171
+ isFailed: preparationFailure || results.some((r) => r.status === 'error' || r.status === 'rejected'),
4172
+ isSending: false, sendingAt: 0, recipientResults: results, mailOptions: task.mailOptions,
4173
+ };
4174
+ const completed = await lease.finish(fields, !this.keepHistory);
4175
+ if (completed && !this.__abortInFlight) this.___notifyPolicyGroups(task, error, info, preparationFailure);
4176
+ return completed;
4177
+ }
4178
+
4179
+ /** @internal Empty groups are silent except unparseable terminal preparation failures. */
4180
+ ___notifyPolicyGroups(task, error, info, preparationFailure) {
4181
+ const summary = summarizePolicyTask(task, true);
4182
+ const groups = summary.recipients;
4183
+ if (groups.sent.length) callHook('onSent', this.onSent, task, info, groups.sent, summary);
4184
+ if (!this.__abortInFlight && (groups.error.length || (preparationFailure && !task.recipientResults.length))) {
4185
+ callHook('onError', this.onError, error || policyError('recipients remain unaccepted'), task, info, groups.error, summary);
4186
+ }
4187
+ if (!this.__abortInFlight && groups.suppressed.length) callHook('onSuppressed', this.onSuppressed, task, groups.suppressed, summary);
4188
+ if (!this.__abortInFlight && groups.rejected.length) callHook('onRejected', this.onRejected, task, groups.rejected, summary);
4189
+ }
4190
+
3191
4191
  /**
3192
4192
  * @async
3193
4193
  * @internal
@@ -3231,6 +4231,12 @@ class MailTime {
3231
4231
  * @returns {Promise<void 0>}
3232
4232
  */
3233
4233
  async ___send(task) {
4234
+ if (this.__recipientPolicies) return await this.___sendWithRecipientPolicies(task);
4235
+ if (task?.isSettled === true) return;
4236
+ if (Array.isArray(task?.recipientResults)) {
4237
+ logError('[recipientPolicies] active policy state requires a policy-configured worker', task.uuid);
4238
+ return;
4239
+ }
3234
4240
  this.__debug('[private send]', task);
3235
4241
  try {
3236
4242
  if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {