mail-time 5.1.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,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,8 @@ 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
+ const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
1160
1218
  if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
1161
1219
  await this.mailTimeInstance.___dispatch(candidate);
1162
1220
  }
@@ -1233,7 +1291,7 @@ class RedisQueue {
1233
1291
  }
1234
1292
 
1235
1293
  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) {
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) {
1237
1295
  return null;
1238
1296
  }
1239
1297
 
@@ -1306,35 +1364,59 @@ class RedisQueue {
1306
1364
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
1307
1365
  */
1308
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) {
1309
1383
  this.__debug('[cancel]', uuid);
1310
1384
  if (typeof uuid !== 'string') {
1311
1385
  return false;
1312
1386
  }
1313
1387
 
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) {
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);
1328
1416
  return false;
1417
+ } finally {
1418
+ if (!this.useHashTags && atomic) await this.client.unwatch?.();
1329
1419
  }
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
1420
  }
1339
1421
 
1340
1422
  /**
@@ -1347,6 +1429,11 @@ class RedisQueue {
1347
1429
  * @returns {Promise<boolean>} returns `true` if removed or `false` if not found
1348
1430
  */
1349
1431
  async remove(task, opts) {
1432
+ return await this.__serialize(() => this.__remove(task, opts));
1433
+ }
1434
+
1435
+ /** @internal */
1436
+ async __remove(task, opts) {
1350
1437
  this.__debug('[remove]', task?.uuid);
1351
1438
  if (!task || typeof task !== 'object' || typeof task.uuid !== 'string') {
1352
1439
  return false;
@@ -1393,6 +1480,8 @@ class RedisQueue {
1393
1480
  || currentTask.isSending !== true
1394
1481
  || (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) !== opts.leaseSendingAt
1395
1482
  || currentTask.isCancelled === true
1483
+ || currentTask.isSent === true
1484
+ || currentTask.isSettled === true
1396
1485
  || currentTask.isFailed === true) {
1397
1486
  await this.client.unwatch?.();
1398
1487
  return false;
@@ -1436,6 +1525,11 @@ class RedisQueue {
1436
1525
  * @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
1437
1526
  */
1438
1527
  async update(task, updateObj) {
1528
+ return await this.__serialize(() => this.__update(task, updateObj));
1529
+ }
1530
+
1531
+ /** @internal */
1532
+ async __update(task, updateObj) {
1439
1533
  this.__debug('[update]', task?.uuid);
1440
1534
  if (!task || typeof task !== 'object' || typeof task.uuid !== 'string' || !updateObj || typeof updateObj !== 'object') {
1441
1535
  return false;
@@ -1446,38 +1540,41 @@ class RedisQueue {
1446
1540
  const isClaim = isSendClaimUpdate(updateObj);
1447
1541
  const isAppend = isAppendMailOptionUpdate(updateObj);
1448
1542
  const isLeaseRelease = isSendLeaseGuardedUpdate(updateObj);
1543
+ if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled')) && !isClaim && !isLeaseRelease) return false;
1449
1544
  const now = isClaim && typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
1450
1545
  const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
1451
1546
 
1452
1547
  try {
1453
1548
  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);
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;
1467
1576
  }
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;
1577
+ return false;
1481
1578
  }
1482
1579
 
1483
1580
  if (isAppend) {
@@ -1491,7 +1588,7 @@ class RedisQueue {
1491
1588
  return false;
1492
1589
  }
1493
1590
  const currentTask = JSON.parse(taskJSON);
1494
- 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)) {
1495
1592
  return false;
1496
1593
  }
1497
1594
  currentTask.mailOptions = [...(currentTask.mailOptions || []), updateObj.appendMailOption];
@@ -1506,7 +1603,7 @@ class RedisQueue {
1506
1603
  return false;
1507
1604
  }
1508
1605
  const currentTask = JSON.parse(taskJSON);
1509
- 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)) {
1510
1607
  await this.client.unwatch?.();
1511
1608
  return false;
1512
1609
  }
@@ -1546,7 +1643,7 @@ class RedisQueue {
1546
1643
  const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
1547
1644
  const multi = this.client.multi();
1548
1645
  multi.set(letterKey, JSON.stringify(updatedTask));
1549
- if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
1646
+ if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true || updatedTask.isSettled === true) {
1550
1647
  multi.del(sendatKey);
1551
1648
  } else if (updatedTask.sendAt) {
1552
1649
  multi.set(sendatKey, `${+updatedTask.sendAt}`);
@@ -1565,7 +1662,7 @@ class RedisQueue {
1565
1662
  const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
1566
1663
  await this.client.set(letterKey, JSON.stringify(updatedTask));
1567
1664
 
1568
- if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
1665
+ if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true || updatedTask.isSettled === true) {
1569
1666
  await this.client.del(sendatKey);
1570
1667
  } else if (updatedTask.sendAt) {
1571
1668
  await this.client.set(sendatKey, `${+updatedTask.sendAt}`);
@@ -1643,6 +1740,8 @@ const fieldMap = {
1643
1740
  tries: 'tries',
1644
1741
  sendAt: 'send_at',
1645
1742
  isSent: 'is_sent',
1743
+ isSettled: 'is_settled',
1744
+ recipientResults: 'recipient_results',
1646
1745
  isCancelled: 'is_cancelled',
1647
1746
  isFailed: 'is_failed',
1648
1747
  isSending: 'is_sending',
@@ -1672,6 +1771,8 @@ const normalizeRow = (row) => {
1672
1771
  tries: parseInt(row.tries, 10),
1673
1772
  sendAt: parseInt(row.send_at, 10),
1674
1773
  isSent: row.is_sent,
1774
+ isSettled: row.is_settled === true,
1775
+ recipientResults: row.recipient_results == null ? void 0 : parseMailOptions(row.recipient_results),
1675
1776
  isCancelled: row.is_cancelled,
1676
1777
  isFailed: row.is_failed,
1677
1778
  isSending: row.is_sending === true,
@@ -1691,6 +1792,7 @@ class PostgresQueue {
1691
1792
  */
1692
1793
  constructor(opts) {
1693
1794
  this.name = 'postgres-queue';
1795
+ this.supportsRecipientPolicies = true;
1694
1796
  if (!opts || typeof opts !== 'object') {
1695
1797
  throw new TypeError('[mail-time] Configuration object must be passed into PostgresQueue constructor');
1696
1798
  }
@@ -1751,6 +1853,8 @@ class PostgresQueue {
1751
1853
  tries INTEGER NOT NULL DEFAULT 0,
1752
1854
  send_at BIGINT NOT NULL,
1753
1855
  is_sent BOOLEAN NOT NULL DEFAULT false,
1856
+ is_settled BOOLEAN NOT NULL DEFAULT false,
1857
+ recipient_results JSONB,
1754
1858
  is_cancelled BOOLEAN NOT NULL DEFAULT false,
1755
1859
  is_failed BOOLEAN NOT NULL DEFAULT false,
1756
1860
  is_sending BOOLEAN NOT NULL DEFAULT false,
@@ -1762,14 +1866,16 @@ class PostgresQueue {
1762
1866
  created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
1763
1867
  updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
1764
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');
1765
1871
  await this.client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
1766
1872
  ON mail_time_queue (prefix, uuid)`);
1767
1873
 
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)`);
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)`);
1770
1876
 
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)`);
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)`);
1773
1879
  } finally {
1774
1880
  await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
1775
1881
  }
@@ -1844,14 +1950,15 @@ class PostgresQueue {
1844
1950
 
1845
1951
  try {
1846
1952
  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
1953
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results
1848
1954
  FROM mail_time_queue
1849
1955
  WHERE prefix = $1
1850
1956
  AND is_sent = false
1851
1957
  AND is_failed = false
1852
1958
  AND is_cancelled = false
1959
+ AND is_settled = false
1853
1960
  AND send_at <= $2
1854
- AND tries < $3
1961
+ AND (tries < $3 OR (recipient_results IS NOT NULL AND is_sending = true AND sending_at <= $4))
1855
1962
  AND (is_sending = false OR sending_at <= $4)
1856
1963
  ORDER BY send_at ASC
1857
1964
  LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
@@ -1882,7 +1989,7 @@ class PostgresQueue {
1882
1989
  await this.ready();
1883
1990
 
1884
1991
  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
1992
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results
1886
1993
  FROM mail_time_queue
1887
1994
  WHERE prefix = $1
1888
1995
  AND to_address = $2
@@ -1890,6 +1997,8 @@ class PostgresQueue {
1890
1997
  AND is_failed = false
1891
1998
  AND is_cancelled = false
1892
1999
  AND is_sending = false
2000
+ AND is_settled = false
2001
+ AND recipient_results IS NULL
1893
2002
  AND tries < $4
1894
2003
  AND send_at <= $3
1895
2004
  ORDER BY send_at DESC
@@ -1920,9 +2029,9 @@ class PostgresQueue {
1920
2029
 
1921
2030
  await this.client.query(`INSERT INTO mail_time_queue (
1922
2031
  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
2032
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results, created_at, updated_at
1924
2033
  )
1925
- 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)
1926
2035
  ON CONFLICT (prefix, uuid) DO UPDATE SET
1927
2036
  to_address = EXCLUDED.to_address,
1928
2037
  tries = EXCLUDED.tries,
@@ -1936,6 +2045,8 @@ class PostgresQueue {
1936
2045
  transport = EXCLUDED.transport,
1937
2046
  concat_subject = EXCLUDED.concat_subject,
1938
2047
  mail_options = EXCLUDED.mail_options,
2048
+ is_settled = EXCLUDED.is_settled,
2049
+ recipient_results = EXCLUDED.recipient_results,
1939
2050
  updated_at = CURRENT_TIMESTAMP`, [
1940
2051
  this.prefix,
1941
2052
  task.uuid,
@@ -1951,6 +2062,8 @@ class PostgresQueue {
1951
2062
  task.transport,
1952
2063
  task.concatSubject || null,
1953
2064
  JSON.stringify(task.mailOptions || []),
2065
+ task.isSettled === true,
2066
+ task.recipientResults === void 0 ? null : JSON.stringify(task.recipientResults),
1954
2067
  ]);
1955
2068
  }
1956
2069
 
@@ -1971,23 +2084,20 @@ class PostgresQueue {
1971
2084
  await this.ready();
1972
2085
 
1973
2086
  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
2087
+ template, transport, concat_subject, mail_options, is_settled, recipient_results
1975
2088
  FROM mail_time_queue
1976
2089
  WHERE prefix = $1
1977
2090
  AND uuid = $2
1978
2091
  LIMIT 1`, [this.prefix, uuid])).rows?.[0]);
1979
2092
 
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
- });
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;
1991
2101
  }
1992
2102
 
1993
2103
  /**
@@ -2013,7 +2123,7 @@ class PostgresQueue {
2013
2123
  const params = [this.prefix, value];
2014
2124
  if (isSendLeaseRemove(opts)) {
2015
2125
  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`;
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`;
2017
2127
  }
2018
2128
 
2019
2129
  const res = await this.client.query(`DELETE FROM mail_time_queue
@@ -2038,6 +2148,8 @@ class PostgresQueue {
2038
2148
  }
2039
2149
 
2040
2150
  await this.ready();
2151
+ if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled'))
2152
+ && !isSendClaimUpdate(updateObj) && !isSendLeaseGuardedUpdate(updateObj)) return false;
2041
2153
 
2042
2154
  if (isAppendMailOptionUpdate(updateObj)) {
2043
2155
  const where = task.id ? 'id = $3' : 'uuid = $3';
@@ -2050,7 +2162,9 @@ class PostgresQueue {
2050
2162
  AND is_sent = false
2051
2163
  AND is_failed = false
2052
2164
  AND is_cancelled = false
2053
- AND is_sending = false`, [
2165
+ AND is_sending = false
2166
+ AND is_settled = false
2167
+ AND recipient_results IS NULL`, [
2054
2168
  JSON.stringify([updateObj.appendMailOption]),
2055
2169
  this.prefix,
2056
2170
  value,
@@ -2070,7 +2184,7 @@ class PostgresQueue {
2070
2184
  if (key === 'sendAt' && value instanceof Date) {
2071
2185
  value = +value;
2072
2186
  }
2073
- if (key === 'mailOptions') {
2187
+ if (key === 'mailOptions' || key === 'recipientResults') {
2074
2188
  value = JSON.stringify(value);
2075
2189
  }
2076
2190
 
@@ -2094,6 +2208,7 @@ class PostgresQueue {
2094
2208
  AND is_sent = false
2095
2209
  AND is_failed = false
2096
2210
  AND is_cancelled = false
2211
+ AND is_settled = false
2097
2212
  AND tries = $${triesIndex}
2098
2213
  AND (is_sending = false OR sending_at <= $${staleIndex})
2099
2214
  `;
@@ -2103,6 +2218,8 @@ class PostgresQueue {
2103
2218
  values.push(updateObj.leaseSendingAt);
2104
2219
  const sendingAtIndex = values.length;
2105
2220
  claimWhere = `
2221
+ AND is_sent = false
2222
+ AND is_settled = false
2106
2223
  AND tries = $${triesIndex}
2107
2224
  AND is_sending = true
2108
2225
  AND sending_at = $${sendingAtIndex}
@@ -2318,6 +2435,316 @@ const mailTimePreset = (name, overrides) => {
2318
2435
  return overrides ? deepMerge(cloned, overrides) : cloned;
2319
2436
  };
2320
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
+
2321
2748
  const noop = () => {};
2322
2749
  const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
2323
2750
 
@@ -2519,11 +2946,32 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2519
2946
  */
2520
2947
 
2521
2948
  /**
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
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
2523
2950
  */
2524
2951
 
2525
2952
  /**
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
2953
+ * @typedef {string | { address: string, name?: string }} MailTimeMailbox
2954
+ */
2955
+
2956
+ /**
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
2527
2975
  */
2528
2976
 
2529
2977
  /**
@@ -2531,7 +2979,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2531
2979
  */
2532
2980
 
2533
2981
  /**
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
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
2535
2983
  */
2536
2984
 
2537
2985
  /**
@@ -2539,7 +2987,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2539
2987
  */
2540
2988
 
2541
2989
  /**
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
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
2543
2991
  */
2544
2992
 
2545
2993
  /**
@@ -2551,10 +2999,16 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
2551
2999
  */
2552
3000
 
2553
3001
  /**
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
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
2555
3003
  */
2556
3004
 
2557
- /** 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
+ */
2558
3012
  class MailTime {
2559
3013
  /**
2560
3014
  * Create a MailTime instance
@@ -2569,6 +3023,7 @@ class MailTime {
2569
3023
  throw new Error('[mail-time] {queue} option is required: provide a MongoQueue, RedisQueue, PostgresQueue, or CustomQueue instance');
2570
3024
  }
2571
3025
 
3026
+ this.__recipientPolicies = validateRecipientPolicies(opts.recipientPolicies, opts.queue);
2572
3027
  this.queue = opts.queue;
2573
3028
 
2574
3029
  for (let i = queueMethods.length - 1; i >= 0; i--) {
@@ -2608,6 +3063,8 @@ class MailTime {
2608
3063
  this.keepHistory = opts.keepHistory === true;
2609
3064
  this.onSent = (typeof opts.onSent === 'function') ? opts.onSent.bind(this) : noop;
2610
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;
2611
3068
 
2612
3069
  this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
2613
3070
  this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
@@ -2820,8 +3277,8 @@ class MailTime {
2820
3277
  /**
2821
3278
  * @memberOf MailTime
2822
3279
  * @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]
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
2825
3282
  * @returns {boolean | Promise<boolean>}
2826
3283
  */
2827
3284
  destroy(opts) {
@@ -2829,14 +3286,29 @@ class MailTime {
2829
3286
  if (this.__isDestroyed) {
2830
3287
  return false;
2831
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
+ }
2832
3293
 
2833
3294
  this.__isDestroyed = true;
2834
3295
  this.__isPaused = false;
2835
- if (this.scheduler && typeof this.scheduler.destroy === 'function') {
2836
- 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
+ })();
2837
3309
  }
2838
- if (opts && opts.drain === true && this.__pool) {
2839
- return this.__pool.drain().then(() => true);
3310
+ if (this.scheduler) {
3311
+ this.scheduler.destroy();
2840
3312
  }
2841
3313
  // No graceful drain requested: neutralize in-flight SMTP completions so a
2842
3314
  // destroyed instance performs no storage writes, no `onSent`/`onError`
@@ -2957,7 +3429,8 @@ class MailTime {
2957
3429
  delete mailOptions.template;
2958
3430
  delete mailOptions.concatSubject;
2959
3431
 
2960
- 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)) {
2961
3434
  throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
2962
3435
  }
2963
3436
 
@@ -3117,6 +3590,7 @@ class MailTime {
3117
3590
  uuid: crypto.randomUUID(),
3118
3591
  tries: 0,
3119
3592
  isSent: false,
3593
+ isSettled: false,
3120
3594
  sendAt: opts.sendAt,
3121
3595
  isFailed: false,
3122
3596
  isSending: false,
@@ -3250,7 +3724,7 @@ class MailTime {
3250
3724
  }
3251
3725
 
3252
3726
  const acceptedSet = collectAcceptedSet(task);
3253
- if (acceptedSet.size > 0) {
3727
+ if (!this.__recipientPolicies && acceptedSet.size > 0) {
3254
3728
  compiledOpts.to = filterAddressField(compiledOpts.to, acceptedSet);
3255
3729
  compiledOpts.cc = filterAddressField(compiledOpts.cc, acceptedSet);
3256
3730
  compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
@@ -3463,7 +3937,7 @@ class MailTime {
3463
3937
  };
3464
3938
 
3465
3939
  const renew = async () => {
3466
- if (stopped || this.__isDestroyed || this.__abortInFlight) {
3940
+ if (stopped || this.__abortInFlight) {
3467
3941
  halt();
3468
3942
  return;
3469
3943
  }
@@ -3516,6 +3990,204 @@ class MailTime {
3516
3990
  return { stop };
3517
3991
  }
3518
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
+
3519
4191
  /**
3520
4192
  * @async
3521
4193
  * @internal
@@ -3559,6 +4231,12 @@ class MailTime {
3559
4231
  * @returns {Promise<void 0>}
3560
4232
  */
3561
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
+ }
3562
4240
  this.__debug('[private send]', task);
3563
4241
  try {
3564
4242
  if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {