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