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/README.md +45 -14
- package/adapters/mongo.d.ts +1 -0
- package/adapters/mongo.js +34 -16
- package/adapters/postgres.d.ts +1 -0
- package/adapters/postgres.js +45 -24
- package/adapters/redis.d.ts +6 -2
- package/adapters/redis.js +153 -72
- package/index.cjs +805 -127
- package/index.d.cts +136 -14
- package/index.d.ts +136 -14
- package/index.js +270 -15
- package/package.json +10 -3
- package/recipient-policy-lease.js +90 -0
- package/recipient-policy.js +166 -0
- package/recipient-rejections.js +62 -0
- package/scripts/migrate-redis-queue-to-cluster.mjs +5 -1
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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.
|
|
634
|
+
return ((await this.collection.deleteOne(query))?.deletedCount || 0) >= 1;
|
|
625
635
|
}
|
|
626
|
-
|
|
627
|
-
return
|
|
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
|
|
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,
|
|
815
|
+
table.insert(tasks, payload)
|
|
793
816
|
end
|
|
794
817
|
elseif tonumber(task.sendAt or 0) <= now then
|
|
795
|
-
table.insert(tasks,
|
|
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],
|
|
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
|
-
|
|
872
|
-
|
|
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] == '
|
|
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
|
|
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
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
?
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
/**
|
|
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 }`,
|
|
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 (
|
|
2836
|
-
|
|
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 (
|
|
2839
|
-
|
|
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
|
-
|
|
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.
|
|
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) {
|