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