mail-time 4.1.0 → 5.1.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 +76 -19
- package/adapters/redis.d.ts +19 -1
- package/adapters/redis.js +337 -8
- package/helpers.js +19 -0
- package/index.cjs +778 -127
- package/index.d.cts +50 -7
- package/index.d.ts +50 -7
- package/index.js +423 -118
- package/package.json +21 -4
- package/presets.js +2 -2
- package/scripts/migrate-redis-queue-to-cluster.mjs +108 -0
package/index.cjs
CHANGED
|
@@ -225,6 +225,24 @@ const filterAddressField = (field, acceptedSet) => {
|
|
|
225
225
|
return field;
|
|
226
226
|
};
|
|
227
227
|
|
|
228
|
+
const HTML_ESCAPES = {
|
|
229
|
+
'&': '&',
|
|
230
|
+
'<': '<',
|
|
231
|
+
'>': '>',
|
|
232
|
+
'"': '"',
|
|
233
|
+
"'": ''',
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @name escapeHtml - Escape the five HTML-significant characters.
|
|
238
|
+
* @function
|
|
239
|
+
* @param {string} value
|
|
240
|
+
* @returns {string} `value` safe to interpolate into HTML text or an attribute value.
|
|
241
|
+
*/
|
|
242
|
+
const escapeHtml = (value) => {
|
|
243
|
+
return `${value}`.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
|
|
244
|
+
};
|
|
245
|
+
|
|
228
246
|
/**
|
|
229
247
|
* @name isSendClaimUpdate - Detect an atomic send-claim update (`{ isSending: true, tries: N }`).
|
|
230
248
|
* @function
|
|
@@ -711,7 +729,11 @@ class MongoQueue {
|
|
|
711
729
|
* @property {(key: string, value: string, options?: object) => Promise<unknown>} set
|
|
712
730
|
* @property {(key: string|string[]) => Promise<number>} del
|
|
713
731
|
* @property {() => Promise<string>} ping
|
|
714
|
-
* @property {(options: object) => AsyncIterable<string|string[]>} scanIterator
|
|
732
|
+
* @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
|
|
733
|
+
* @property {(key: string, field: string) => Promise<string|null>} [hGet]
|
|
734
|
+
* @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
|
|
735
|
+
* @property {(sha: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [evalSha]
|
|
736
|
+
* @property {(script: string) => Promise<string>} [scriptLoad]
|
|
715
737
|
* @property {(key: string) => Promise<unknown>} [watch]
|
|
716
738
|
* @property {() => Promise<unknown>} [unwatch]
|
|
717
739
|
* @property {() => object} [multi]
|
|
@@ -721,10 +743,168 @@ class MongoQueue {
|
|
|
721
743
|
* @typedef {object} RedisQueueOption
|
|
722
744
|
* @property {RedisClient} client
|
|
723
745
|
* @property {string} [prefix]
|
|
746
|
+
* @property {boolean} [useHashTags] - Use Redis Cluster hash-tag keys (`mailtime:{prefix}:*`). Default keeps existing standalone keys.
|
|
724
747
|
*/
|
|
725
748
|
|
|
726
749
|
const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
|
|
727
750
|
const DEFAULT_PREFIX$1 = 'default';
|
|
751
|
+
const VALID_PREFIX = /^[A-Za-z0-9_\-:.]+$/;
|
|
752
|
+
const TAGGED_ITERATE_LIMIT = 100;
|
|
753
|
+
|
|
754
|
+
const PUSH_TAGGED_TASK_SCRIPT = `
|
|
755
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
756
|
+
redis.call('ZADD', KEYS[2], tonumber(ARGV[3]), ARGV[1])
|
|
757
|
+
if #KEYS > 3 then
|
|
758
|
+
redis.call('SET', KEYS[3], ARGV[1], 'PXAT', tonumber(ARGV[4]))
|
|
759
|
+
redis.call('ZADD', KEYS[4], tonumber(ARGV[4]), KEYS[3])
|
|
760
|
+
end
|
|
761
|
+
return 1
|
|
762
|
+
`;
|
|
763
|
+
|
|
764
|
+
const ITERATE_TAGGED_TASKS_SCRIPT = `
|
|
765
|
+
local now = tonumber(ARGV[1])
|
|
766
|
+
local maxTries = tonumber(ARGV[2])
|
|
767
|
+
local sendingTimeout = tonumber(ARGV[3])
|
|
768
|
+
local limit = tonumber(ARGV[4])
|
|
769
|
+
local scanLimit = tonumber(ARGV[5])
|
|
770
|
+
local tasks = {}
|
|
771
|
+
if #KEYS > 2 then
|
|
772
|
+
local expiredPointers = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now, 'LIMIT', 0, 100)
|
|
773
|
+
for _, pointerKey in ipairs(expiredPointers) do
|
|
774
|
+
redis.call('DEL', pointerKey)
|
|
775
|
+
redis.call('ZREM', KEYS[3], pointerKey)
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
local due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'LIMIT', 0, scanLimit)
|
|
779
|
+
for _, uuid in ipairs(due) do
|
|
780
|
+
local payload = redis.call('HGET', KEYS[1], uuid)
|
|
781
|
+
if not payload then
|
|
782
|
+
redis.call('ZREM', KEYS[2], uuid)
|
|
783
|
+
else
|
|
784
|
+
local task = cjson.decode(payload)
|
|
785
|
+
if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) >= maxTries then
|
|
786
|
+
redis.call('ZREM', KEYS[2], uuid)
|
|
787
|
+
elseif task.isSending then
|
|
788
|
+
local eligibleAt = tonumber(task.sendingAt or 0) + sendingTimeout
|
|
789
|
+
if eligibleAt > now then
|
|
790
|
+
redis.call('ZADD', KEYS[2], eligibleAt, uuid)
|
|
791
|
+
else
|
|
792
|
+
table.insert(tasks, task)
|
|
793
|
+
end
|
|
794
|
+
elseif tonumber(task.sendAt or 0) <= now then
|
|
795
|
+
table.insert(tasks, task)
|
|
796
|
+
else
|
|
797
|
+
redis.call('ZADD', KEYS[2], tonumber(task.sendAt), uuid)
|
|
798
|
+
end
|
|
799
|
+
if #tasks >= limit then
|
|
800
|
+
break
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
return cjson.encode(tasks)
|
|
806
|
+
`;
|
|
807
|
+
|
|
808
|
+
const UPDATE_TAGGED_TASK_SCRIPT = `
|
|
809
|
+
local payload = redis.call('HGET', KEYS[1], ARGV[1])
|
|
810
|
+
if not payload then
|
|
811
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
812
|
+
return 0
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
local task = cjson.decode(payload)
|
|
816
|
+
local update = cjson.decode(ARGV[2])
|
|
817
|
+
local mode = ARGV[3]
|
|
818
|
+
local now = tonumber(ARGV[4])
|
|
819
|
+
local sendingTimeout = tonumber(ARGV[5])
|
|
820
|
+
local expectedTries = tonumber(ARGV[6])
|
|
821
|
+
local leaseSendingAt = tonumber(ARGV[7])
|
|
822
|
+
|
|
823
|
+
if mode == 'claim' then
|
|
824
|
+
if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) ~= expectedTries then
|
|
825
|
+
return 0
|
|
826
|
+
end
|
|
827
|
+
if task.isSending and tonumber(task.sendingAt or 0) > now - sendingTimeout then
|
|
828
|
+
return 0
|
|
829
|
+
end
|
|
830
|
+
elseif mode == 'lease' then
|
|
831
|
+
if task.isCancelled or task.isFailed or not task.isSending
|
|
832
|
+
or tonumber(task.tries or 0) ~= expectedTries
|
|
833
|
+
or tonumber(task.sendingAt or 0) ~= leaseSendingAt then
|
|
834
|
+
return 0
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
for key, value in pairs(update) do
|
|
839
|
+
task[key] = value
|
|
840
|
+
end
|
|
841
|
+
redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
|
|
842
|
+
|
|
843
|
+
if task.isSent or task.isFailed or task.isCancelled then
|
|
844
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
845
|
+
if #KEYS > 3 then
|
|
846
|
+
local concatUuid = redis.call('GET', KEYS[3])
|
|
847
|
+
if concatUuid == ARGV[1] then
|
|
848
|
+
redis.call('DEL', KEYS[3])
|
|
849
|
+
redis.call('ZREM', KEYS[4], KEYS[3])
|
|
850
|
+
elseif not concatUuid then
|
|
851
|
+
redis.call('ZREM', KEYS[4], KEYS[3])
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
elseif task.isSending then
|
|
855
|
+
redis.call('ZADD', KEYS[2], tonumber(task.sendingAt or now) + sendingTimeout, ARGV[1])
|
|
856
|
+
else
|
|
857
|
+
redis.call('ZADD', KEYS[2], tonumber(task.sendAt), ARGV[1])
|
|
858
|
+
end
|
|
859
|
+
return 1
|
|
860
|
+
`;
|
|
861
|
+
|
|
862
|
+
const APPEND_TAGGED_MAIL_OPTION_SCRIPT = `
|
|
863
|
+
local payload = redis.call('HGET', KEYS[1], ARGV[1])
|
|
864
|
+
if not payload then
|
|
865
|
+
return 0
|
|
866
|
+
end
|
|
867
|
+
local task = cjson.decode(payload)
|
|
868
|
+
if task.isSending or task.isSent or task.isFailed or task.isCancelled then
|
|
869
|
+
return 0
|
|
870
|
+
end
|
|
871
|
+
task.mailOptions = task.mailOptions or {}
|
|
872
|
+
table.insert(task.mailOptions, cjson.decode(ARGV[2]))
|
|
873
|
+
redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
|
|
874
|
+
return 1
|
|
875
|
+
`;
|
|
876
|
+
|
|
877
|
+
const REMOVE_TAGGED_TASK_SCRIPT = `
|
|
878
|
+
local payload = redis.call('HGET', KEYS[1], ARGV[1])
|
|
879
|
+
if not payload then
|
|
880
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
881
|
+
return 0
|
|
882
|
+
end
|
|
883
|
+
local task = cjson.decode(payload)
|
|
884
|
+
if ARGV[2] == 'lease' and (task.isCancelled or task.isFailed or not task.isSending
|
|
885
|
+
or tonumber(task.tries or 0) ~= tonumber(ARGV[3])
|
|
886
|
+
or tonumber(task.sendingAt or 0) ~= tonumber(ARGV[4])) then
|
|
887
|
+
return 0
|
|
888
|
+
end
|
|
889
|
+
redis.call('HDEL', KEYS[1], ARGV[1])
|
|
890
|
+
redis.call('ZREM', KEYS[2], ARGV[1])
|
|
891
|
+
if #KEYS > 3 then
|
|
892
|
+
local concatUuid = redis.call('GET', KEYS[3])
|
|
893
|
+
if concatUuid == ARGV[1] then
|
|
894
|
+
redis.call('DEL', KEYS[3])
|
|
895
|
+
redis.call('ZREM', KEYS[4], KEYS[3])
|
|
896
|
+
elseif not concatUuid then
|
|
897
|
+
redis.call('ZREM', KEYS[4], KEYS[3])
|
|
898
|
+
end
|
|
899
|
+
end
|
|
900
|
+
return 1
|
|
901
|
+
`;
|
|
902
|
+
|
|
903
|
+
const sha1Hex = (string) => crypto.createHash('sha1').update(string).digest('hex');
|
|
904
|
+
|
|
905
|
+
const isNoScriptError = (error) => {
|
|
906
|
+
return !!error && (error.code === 'NOSCRIPT' || (typeof error.message === 'string' && error.message.includes('NOSCRIPT')));
|
|
907
|
+
};
|
|
728
908
|
|
|
729
909
|
const canReleaseLease = (currentTask, updateObj) => {
|
|
730
910
|
return currentTask
|
|
@@ -798,7 +978,21 @@ class RedisQueue {
|
|
|
798
978
|
throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
|
|
799
979
|
}
|
|
800
980
|
|
|
981
|
+
if (opts.useHashTags !== undefined && typeof opts.useHashTags !== 'boolean') {
|
|
982
|
+
throw new TypeError(`[mail-time] [RedisQueue] {useHashTags} option must be a boolean (received: ${typeof opts.useHashTags})`);
|
|
983
|
+
}
|
|
984
|
+
|
|
801
985
|
this.client = opts.client;
|
|
986
|
+
this.useHashTags = opts.useHashTags === true;
|
|
987
|
+
this.__scriptSources = {
|
|
988
|
+
push: PUSH_TAGGED_TASK_SCRIPT,
|
|
989
|
+
iterate: ITERATE_TAGGED_TASKS_SCRIPT,
|
|
990
|
+
update: UPDATE_TAGGED_TASK_SCRIPT,
|
|
991
|
+
append: APPEND_TAGGED_MAIL_OPTION_SCRIPT,
|
|
992
|
+
remove: REMOVE_TAGGED_TASK_SCRIPT,
|
|
993
|
+
};
|
|
994
|
+
this.__scriptShas = Object.fromEntries(Object.entries(this.__scriptSources).map(([name, source]) => [name, sha1Hex(source)]));
|
|
995
|
+
this.__loadedShas = new Set();
|
|
802
996
|
if (typeof opts.prefix === 'string') {
|
|
803
997
|
this.__applyPrefix(opts.prefix);
|
|
804
998
|
}
|
|
@@ -806,8 +1000,16 @@ class RedisQueue {
|
|
|
806
1000
|
|
|
807
1001
|
/** @internal */
|
|
808
1002
|
__applyPrefix(prefix) {
|
|
1003
|
+
if (this.useHashTags && !VALID_PREFIX.test(prefix)) {
|
|
1004
|
+
throw new Error(`[mail-time] [RedisQueue] {prefix} option must match ${VALID_PREFIX} when {useHashTags} is true (received: "${prefix}")`);
|
|
1005
|
+
}
|
|
809
1006
|
this.prefix = prefix;
|
|
810
|
-
this.uniqueName = `mailtime:${prefix}`;
|
|
1007
|
+
this.uniqueName = this.useHashTags ? `mailtime:{${prefix}}` : `mailtime:${prefix}`;
|
|
1008
|
+
if (this.useHashTags) {
|
|
1009
|
+
this.lettersKey = `${this.uniqueName}:letters`;
|
|
1010
|
+
this.scheduleKey = `${this.uniqueName}:schedule`;
|
|
1011
|
+
this.concatKeysKey = `${this.uniqueName}:concatkeys`;
|
|
1012
|
+
}
|
|
811
1013
|
}
|
|
812
1014
|
|
|
813
1015
|
/** @internal */
|
|
@@ -822,6 +1024,49 @@ class RedisQueue {
|
|
|
822
1024
|
debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
|
|
823
1025
|
}
|
|
824
1026
|
|
|
1027
|
+
/** @internal */
|
|
1028
|
+
async __runScript(scriptKey, options) {
|
|
1029
|
+
const source = this.__scriptSources[scriptKey];
|
|
1030
|
+
const sha = this.__scriptShas[scriptKey];
|
|
1031
|
+
if (!source || !sha) {
|
|
1032
|
+
throw new Error(`[mail-time] [RedisQueue] unknown script "${scriptKey}"`);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (this.__loadedShas.has(sha) && typeof this.client.evalSha === 'function') {
|
|
1036
|
+
try {
|
|
1037
|
+
return await this.client.evalSha(sha, options);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
if (!isNoScriptError(error)) {
|
|
1040
|
+
throw error;
|
|
1041
|
+
}
|
|
1042
|
+
this.__loadedShas.delete(sha);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (typeof this.client.scriptLoad === 'function' && typeof this.client.evalSha === 'function') {
|
|
1047
|
+
try {
|
|
1048
|
+
await this.client.scriptLoad(source);
|
|
1049
|
+
this.__loadedShas.add(sha);
|
|
1050
|
+
return await this.client.evalSha(sha, options);
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
if (!isNoScriptError(error)) {
|
|
1053
|
+
this.__debug(`[script:${scriptKey}] scriptLoad failed; falling back to EVAL`, error);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (typeof this.client.eval !== 'function') {
|
|
1059
|
+
throw new Error('[mail-time] [RedisQueue] Redis Cluster client must support EVAL');
|
|
1060
|
+
}
|
|
1061
|
+
return await this.client.eval(source, options);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** @internal */
|
|
1065
|
+
__getTaggedConcatKey(to) {
|
|
1066
|
+
this.__ensurePrefix();
|
|
1067
|
+
return `${this.uniqueName}:concatletter:${to}`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
825
1070
|
/**
|
|
826
1071
|
* @async
|
|
827
1072
|
* @memberOf RedisQueue
|
|
@@ -899,6 +1144,25 @@ class RedisQueue {
|
|
|
899
1144
|
const maxTries = (this.mailTimeInstance && typeof this.mailTimeInstance.maxTries === 'number')
|
|
900
1145
|
? this.mailTimeInstance.maxTries
|
|
901
1146
|
: 60;
|
|
1147
|
+
|
|
1148
|
+
if (this.useHashTags) {
|
|
1149
|
+
this.__ensurePrefix();
|
|
1150
|
+
const dispatchLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TAGGED_ITERATE_LIMIT;
|
|
1151
|
+
const payload = await this.__runScript('iterate', {
|
|
1152
|
+
keys: [this.lettersKey, this.scheduleKey, this.concatKeysKey],
|
|
1153
|
+
arguments: [`${now}`, `${maxTries}`, `${sendingTimeout}`, `${dispatchLimit}`, `${Math.max(dispatchLimit * 20, 100)}`],
|
|
1154
|
+
});
|
|
1155
|
+
const candidates = payload ? JSON.parse(String(payload)) : [];
|
|
1156
|
+
if (!Array.isArray(candidates)) {
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
for (const candidate of candidates) {
|
|
1160
|
+
if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
|
|
1161
|
+
await this.mailTimeInstance.___dispatch(candidate);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
902
1166
|
let dispatched = 0;
|
|
903
1167
|
|
|
904
1168
|
const matchPattern = this.__getKey('*', 'sendat');
|
|
@@ -955,14 +1219,15 @@ class RedisQueue {
|
|
|
955
1219
|
return null;
|
|
956
1220
|
}
|
|
957
1221
|
|
|
958
|
-
const concatKey = this.__getKey(to, 'concatletter');
|
|
1222
|
+
const concatKey = this.useHashTags ? this.__getTaggedConcatKey(to) : this.__getKey(to, 'concatletter');
|
|
959
1223
|
const uuid = await this.client.get(concatKey);
|
|
960
1224
|
if (!uuid) {
|
|
961
1225
|
return null;
|
|
962
1226
|
}
|
|
963
1227
|
|
|
964
|
-
const
|
|
965
|
-
|
|
1228
|
+
const taskJSON = this.useHashTags
|
|
1229
|
+
? await this.client.hGet(this.lettersKey, uuid)
|
|
1230
|
+
: await this.client.get(this.__getKey(uuid, 'letter'));
|
|
966
1231
|
if (!taskJSON) {
|
|
967
1232
|
return null;
|
|
968
1233
|
}
|
|
@@ -993,6 +1258,19 @@ class RedisQueue {
|
|
|
993
1258
|
task.sendAt = +task.sendAt;
|
|
994
1259
|
}
|
|
995
1260
|
|
|
1261
|
+
if (this.useHashTags) {
|
|
1262
|
+
this.__ensurePrefix();
|
|
1263
|
+
const keys = [this.lettersKey, this.scheduleKey];
|
|
1264
|
+
const args = [task.uuid, JSON.stringify(task), `${+task.sendAt}`];
|
|
1265
|
+
if (task.to) {
|
|
1266
|
+
keys.push(this.__getTaggedConcatKey(task.to));
|
|
1267
|
+
keys.push(this.concatKeysKey);
|
|
1268
|
+
args.push(`${task.sendAt - 128}`);
|
|
1269
|
+
}
|
|
1270
|
+
await this.__runScript('push', { keys, arguments: args });
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
996
1274
|
const letterKey = this.__getKey(task.uuid, 'letter');
|
|
997
1275
|
const sendatKey = this.__getKey(task.uuid, 'sendat');
|
|
998
1276
|
const taskJSON = JSON.stringify(task);
|
|
@@ -1033,9 +1311,14 @@ class RedisQueue {
|
|
|
1033
1311
|
return false;
|
|
1034
1312
|
}
|
|
1035
1313
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1314
|
+
if (this.useHashTags) {
|
|
1315
|
+
this.__ensurePrefix();
|
|
1316
|
+
} else {
|
|
1317
|
+
await this.client.del(this.__getKey(uuid, 'sendat'));
|
|
1318
|
+
}
|
|
1319
|
+
const taskJSON = this.useHashTags
|
|
1320
|
+
? await this.client.hGet(this.lettersKey, uuid)
|
|
1321
|
+
: await this.client.get(this.__getKey(uuid, 'letter'));
|
|
1039
1322
|
if (!taskJSON) {
|
|
1040
1323
|
return false;
|
|
1041
1324
|
}
|
|
@@ -1069,6 +1352,30 @@ class RedisQueue {
|
|
|
1069
1352
|
return false;
|
|
1070
1353
|
}
|
|
1071
1354
|
|
|
1355
|
+
if (this.useHashTags) {
|
|
1356
|
+
this.__ensurePrefix();
|
|
1357
|
+
try {
|
|
1358
|
+
const keys = [this.lettersKey, this.scheduleKey];
|
|
1359
|
+
if (task.to) {
|
|
1360
|
+
keys.push(this.__getTaggedConcatKey(task.to));
|
|
1361
|
+
keys.push(this.concatKeysKey);
|
|
1362
|
+
}
|
|
1363
|
+
const result = await this.__runScript('remove', {
|
|
1364
|
+
keys,
|
|
1365
|
+
arguments: [
|
|
1366
|
+
task.uuid,
|
|
1367
|
+
isSendLeaseRemove(opts) ? 'lease' : 'plain',
|
|
1368
|
+
`${opts?.leaseTries || 0}`,
|
|
1369
|
+
`${opts?.leaseSendingAt || 0}`,
|
|
1370
|
+
],
|
|
1371
|
+
});
|
|
1372
|
+
return Number(result) >= 1;
|
|
1373
|
+
} catch (opError) {
|
|
1374
|
+
logError('[remove] [tagged] [opError]', opError);
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1072
1379
|
const letterKey = this.__getKey(task.uuid, 'letter');
|
|
1073
1380
|
if (isSendLeaseRemove(opts)) {
|
|
1074
1381
|
if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
|
|
@@ -1143,6 +1450,36 @@ class RedisQueue {
|
|
|
1143
1450
|
const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
|
|
1144
1451
|
|
|
1145
1452
|
try {
|
|
1453
|
+
if (this.useHashTags) {
|
|
1454
|
+
if (isAppend) {
|
|
1455
|
+
const result = await this.__runScript('append', {
|
|
1456
|
+
keys: [this.lettersKey],
|
|
1457
|
+
arguments: [task.uuid, JSON.stringify(updateObj.appendMailOption)],
|
|
1458
|
+
});
|
|
1459
|
+
return Number(result) >= 1;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const mode = isClaim ? 'claim' : (isLeaseRelease ? 'lease' : 'plain');
|
|
1463
|
+
const keys = [this.lettersKey, this.scheduleKey];
|
|
1464
|
+
if (task.to) {
|
|
1465
|
+
keys.push(this.__getTaggedConcatKey(task.to));
|
|
1466
|
+
keys.push(this.concatKeysKey);
|
|
1467
|
+
}
|
|
1468
|
+
const result = await this.__runScript('update', {
|
|
1469
|
+
keys,
|
|
1470
|
+
arguments: [
|
|
1471
|
+
task.uuid,
|
|
1472
|
+
JSON.stringify(stripInternalUpdateMeta(updateObj)),
|
|
1473
|
+
mode,
|
|
1474
|
+
`${now}`,
|
|
1475
|
+
`${sendingTimeout}`,
|
|
1476
|
+
`${isClaim ? task.tries : (updateObj.leaseTries || 0)}`,
|
|
1477
|
+
`${updateObj.leaseSendingAt || 0}`,
|
|
1478
|
+
],
|
|
1479
|
+
});
|
|
1480
|
+
return Number(result) >= 1;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1146
1483
|
if (isAppend) {
|
|
1147
1484
|
if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
|
|
1148
1485
|
if (!RedisQueue.__atomicAppendWarned) {
|
|
@@ -1254,6 +1591,15 @@ class RedisQueue {
|
|
|
1254
1591
|
throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
|
|
1255
1592
|
}
|
|
1256
1593
|
this.__ensurePrefix();
|
|
1594
|
+
if (this.useHashTags) {
|
|
1595
|
+
if (type === 'letter') {
|
|
1596
|
+
return this.lettersKey;
|
|
1597
|
+
}
|
|
1598
|
+
if (type === 'sendat') {
|
|
1599
|
+
return this.scheduleKey;
|
|
1600
|
+
}
|
|
1601
|
+
return this.__getTaggedConcatKey(uuid);
|
|
1602
|
+
}
|
|
1257
1603
|
return `${this.uniqueName}:${type}:${uuid}`;
|
|
1258
1604
|
}
|
|
1259
1605
|
}
|
|
@@ -1855,7 +2201,7 @@ const PRESETS = Object.freeze({
|
|
|
1855
2201
|
retries: 5,
|
|
1856
2202
|
retryDelay: 2000,
|
|
1857
2203
|
revolvingInterval: 1024,
|
|
1858
|
-
sendingTimeout:
|
|
2204
|
+
sendingTimeout: 120_000,
|
|
1859
2205
|
mode: 'batch',
|
|
1860
2206
|
concurrency: 4,
|
|
1861
2207
|
onError: defaultPresetOnError,
|
|
@@ -1908,7 +2254,7 @@ const PRESETS = Object.freeze({
|
|
|
1908
2254
|
retries: 20,
|
|
1909
2255
|
retryDelay: 5000,
|
|
1910
2256
|
revolvingInterval: 1024,
|
|
1911
|
-
sendingTimeout:
|
|
2257
|
+
sendingTimeout: 120_000,
|
|
1912
2258
|
mode: 'batch',
|
|
1913
2259
|
concurrency: 2,
|
|
1914
2260
|
onError: defaultPresetOnError,
|
|
@@ -1975,6 +2321,51 @@ const mailTimePreset = (name, overrides) => {
|
|
|
1975
2321
|
const noop = () => {};
|
|
1976
2322
|
const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
|
|
1977
2323
|
|
|
2324
|
+
const callHook = (name, callback, ...args) => {
|
|
2325
|
+
try {
|
|
2326
|
+
const result = callback(...args);
|
|
2327
|
+
if (result && typeof result.then === 'function') {
|
|
2328
|
+
Promise.resolve(result).catch((hookError) => {
|
|
2329
|
+
logError(`[${name}] callback failed`, hookError);
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
} catch (hookError) {
|
|
2333
|
+
logError(`[${name}] callback failed`, hookError);
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2336
|
+
|
|
2337
|
+
/**
|
|
2338
|
+
* Floor below which `sendingTimeout` is likely shorter than a real SMTP roundtrip
|
|
2339
|
+
* (connect + greeting + STARTTLS + envelope + DATA, times MX rollover). A claim that
|
|
2340
|
+
* expires mid-send is re-claimed by a peer, which delivers the same letter twice.
|
|
2341
|
+
*/
|
|
2342
|
+
const MIN_SAFE_SENDING_TIMEOUT = 120000;
|
|
2343
|
+
const DEFAULT_MAX_RENEWALS = 10;
|
|
2344
|
+
|
|
2345
|
+
/**
|
|
2346
|
+
* Fields a queued task may set under `{strictPayload: true}`. Everything else —
|
|
2347
|
+
* `attachments`, `envelope`, `dkim`, `raw`, `icalEvent`, … — is dropped, because a
|
|
2348
|
+
* process with queue-write access would otherwise inherit nodemailer's whole
|
|
2349
|
+
* capability surface (local file read, URL fetch, sender/DKIM override).
|
|
2350
|
+
*/
|
|
2351
|
+
const STRICT_MAIL_FIELDS = [
|
|
2352
|
+
'to',
|
|
2353
|
+
'cc',
|
|
2354
|
+
'bcc',
|
|
2355
|
+
'replyTo',
|
|
2356
|
+
'subject',
|
|
2357
|
+
'text',
|
|
2358
|
+
'html',
|
|
2359
|
+
'headers',
|
|
2360
|
+
'list',
|
|
2361
|
+
'priority',
|
|
2362
|
+
'encoding',
|
|
2363
|
+
'textEncoding',
|
|
2364
|
+
'inReplyTo',
|
|
2365
|
+
'references',
|
|
2366
|
+
'date',
|
|
2367
|
+
];
|
|
2368
|
+
|
|
1978
2369
|
const __withLease = (task, updateObj) => {
|
|
1979
2370
|
if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
|
|
1980
2371
|
return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
|
|
@@ -2093,7 +2484,7 @@ const buildRejectionErrorMap = (info) => {
|
|
|
2093
2484
|
return map;
|
|
2094
2485
|
};
|
|
2095
2486
|
|
|
2096
|
-
let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml><meta content="text/html; charset=utf-8"http-equiv=Content-Type><meta content="width=device-width,initial-scale=1"name=viewport><title>{{subject}}</title><style>body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;font-family:Tiempos,Georgia,Times,serif;font-weight:400;width:100%;height:100%;background:#fff;font-size:15px;color:#000;line-height:1.5}a{text-decoration:underline;border:0;color:#000;outline:0;color:inherit}a:hover{text-decoration:none}a[href^=sms],a[href^=tel]{text-decoration:none;color:#000;cursor:default}a img{border:none;text-decoration:none}td{font-family:Tiempos,Georgia,Times,serif;font-weight:400}hr{height:1px;border:none;width:100%;margin:0;margin-top:25px;margin-bottom:25px;background-color:#ECECEC}h1,h2,h3,h4,h5,h6{font-family:HelveticaNeue,"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;line-height:normal;margin-top:35px;margin-bottom:4px;margin-left:0;margin-right:0}h1{margin:23px 15px;font-size:25px}h2{margin-top:15px;font-size:21px}h3{font-weight:400;font-size:19px;border-bottom:1px solid #ECECEC}h4{font-weight:400;font-size:18px}h5{font-weight:400;font-size:17px}h6{font-weight:600;font-size:16px}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{text-decoration:none}pre{font-family:Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;display:block;font-size:13px;padding:9.5px;margin:0 0 10px;line-height:1.42;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px;text-align:left!important;max-width:100%;white-space:pre-wrap;width:auto;overflow:auto}code{font-size:13px;font-family:font-family: Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;border:1px solid rgba(0,0,0,.223);border-radius:2px;padding:1px 2px;word-break:break-all;word-wrap:break-word}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border:none;border-radius:0;word-break:break-all;word-wrap:break-word}td{text-align:center}table{border-collapse:collapse!important}.force-full-width{width:100%!important}</style><style media=screen>@media screen{h1,h2,h3,h4,h5,h6{font-family:\'Helvetica Neue\',Arial,sans-serif!important}td{font-family:Tiempos,Georgia,Times,serif!important}code,pre{font-family:Consolas,Menlo,Monaco,\'Lucida Console\',\'Liberation Mono\',\'DejaVu Sans Mono\',\'Bitstream Vera Sans Mono\',\'Courier New\',monospace,sans-serif!important}}</style><style media="only screen and (max-width:480px)">@media only screen and (max-width:480px){table[class=w320]{width:100%!important}}</style><body bgcolor=#FFFFFF class=body style=padding:0;margin:0;display:block;background:#fff;-webkit-text-size-adjust:none><table cellpadding=0 cellspacing=0 width=100% align=center><tr><td align=center valign=top bgcolor=#FFFFFF width=100%><center><table cellpadding=0 cellspacing=0 width=600 style="margin:0 auto"class=w320><tr><td align=center valign=top><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto;border-bottom:1px solid #ddd"bgcolor=#ECECEC><tr><td><h1>{{
|
|
2487
|
+
let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml><meta content="text/html; charset=utf-8"http-equiv=Content-Type><meta content="width=device-width,initial-scale=1"name=viewport><title>{{subject}}</title><style>body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;font-family:Tiempos,Georgia,Times,serif;font-weight:400;width:100%;height:100%;background:#fff;font-size:15px;color:#000;line-height:1.5}a{text-decoration:underline;border:0;color:#000;outline:0;color:inherit}a:hover{text-decoration:none}a[href^=sms],a[href^=tel]{text-decoration:none;color:#000;cursor:default}a img{border:none;text-decoration:none}td{font-family:Tiempos,Georgia,Times,serif;font-weight:400}hr{height:1px;border:none;width:100%;margin:0;margin-top:25px;margin-bottom:25px;background-color:#ECECEC}h1,h2,h3,h4,h5,h6{font-family:HelveticaNeue,"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;line-height:normal;margin-top:35px;margin-bottom:4px;margin-left:0;margin-right:0}h1{margin:23px 15px;font-size:25px}h2{margin-top:15px;font-size:21px}h3{font-weight:400;font-size:19px;border-bottom:1px solid #ECECEC}h4{font-weight:400;font-size:18px}h5{font-weight:400;font-size:17px}h6{font-weight:600;font-size:16px}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{text-decoration:none}pre{font-family:Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;display:block;font-size:13px;padding:9.5px;margin:0 0 10px;line-height:1.42;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px;text-align:left!important;max-width:100%;white-space:pre-wrap;width:auto;overflow:auto}code{font-size:13px;font-family:font-family: Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;border:1px solid rgba(0,0,0,.223);border-radius:2px;padding:1px 2px;word-break:break-all;word-wrap:break-word}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border:none;border-radius:0;word-break:break-all;word-wrap:break-word}td{text-align:center}table{border-collapse:collapse!important}.force-full-width{width:100%!important}</style><style media=screen>@media screen{h1,h2,h3,h4,h5,h6{font-family:\'Helvetica Neue\',Arial,sans-serif!important}td{font-family:Tiempos,Georgia,Times,serif!important}code,pre{font-family:Consolas,Menlo,Monaco,\'Lucida Console\',\'Liberation Mono\',\'DejaVu Sans Mono\',\'Bitstream Vera Sans Mono\',\'Courier New\',monospace,sans-serif!important}}</style><style media="only screen and (max-width:480px)">@media only screen and (max-width:480px){table[class=w320]{width:100%!important}}</style><body bgcolor=#FFFFFF class=body style=padding:0;margin:0;display:block;background:#fff;-webkit-text-size-adjust:none><table cellpadding=0 cellspacing=0 width=100% align=center><tr><td align=center valign=top bgcolor=#FFFFFF width=100%><center><table cellpadding=0 cellspacing=0 width=600 style="margin:0 auto"class=w320><tr><td align=center valign=top><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto;border-bottom:1px solid #ddd"bgcolor=#ECECEC><tr><td><h1>{{subject}}</h1></table><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"bgcolor=#F2F2F2><tr><td><center><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"><tr><td align=left style="text-align:left;padding:30px 25px">{{{html}}}</table></center></table></table></center></table>';
|
|
2097
2488
|
|
|
2098
2489
|
/**
|
|
2099
2490
|
* @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
|
|
@@ -2124,7 +2515,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
2124
2515
|
*/
|
|
2125
2516
|
|
|
2126
2517
|
/**
|
|
2127
|
-
* @typedef {{ [key: string]: any, adapter: MailTimeJoSkAdapterOptions | object, debug?: boolean, autoClear?: boolean, zombieTime?: number, minRevolvingDelay?: number, maxRevolvingDelay?: number, execute?: 'batch' | 'one', concurrency?: number, lockOwnerId?: string, resetOnInit?: boolean, onError?: (title: string, details: object) => void, onExecuted?: (uid: string, details: object) => void }} MailTimeJoSkOptions
|
|
2518
|
+
* @typedef {{ [key: string]: any, adapter: MailTimeJoSkAdapterOptions | object, debug?: boolean, autoClear?: boolean, zombieTime?: number, lockLeaseTime?: number, minRevolvingDelay?: number, maxRevolvingDelay?: number, execute?: 'batch' | 'one', concurrency?: number, lockOwnerId?: string, resetOnInit?: boolean, onError?: (title: string, details: object) => void, onExecuted?: (uid: string, details: object) => void }} MailTimeJoSkOptions
|
|
2519
|
+
*/
|
|
2520
|
+
|
|
2521
|
+
/**
|
|
2522
|
+
* @typedef {{ [key: string]: any, ping: () => Promise<MailTimePingResult>, setInterval: (func: (...args: any[]) => unknown, delay: number, uid: string) => Promise<string>, destroy: () => boolean, pause: (timerId?: string) => boolean, resume: (timerId?: string) => boolean }} MailTimeScheduler
|
|
2128
2523
|
*/
|
|
2129
2524
|
|
|
2130
2525
|
/**
|
|
@@ -2152,7 +2547,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
2152
2547
|
*/
|
|
2153
2548
|
|
|
2154
2549
|
/**
|
|
2155
|
-
* @typedef {{
|
|
2550
|
+
* @typedef {{ index: number, from: string | undefined }} MailTimeFromDetails
|
|
2551
|
+
*/
|
|
2552
|
+
|
|
2553
|
+
/**
|
|
2554
|
+
* @typedef {{ queue: RedisQueue | MongoQueue | PostgresQueue | CustomQueue, type?: 'server' | 'client', from?: string | ((transport: MailTimeTransport, details: MailTimeFromDetails) => string), transports?: MailTimeTransport[], strategy?: 'backup' | 'balancer', failsToNext?: number, shouldFailOver?: (error: unknown, info: object | undefined, email: MailTimeTask) => boolean, retries?: number, maxTries?: number, retryDelay?: number, interval?: number, keepHistory?: boolean, concatEmails?: boolean | MailTimeConcatEmailsOptions, concatSubject?: string, concatDelimiter?: string, concatDelay?: number, concatThrottling?: number, revolvingInterval?: number, mode?: 'one' | 'batch', concurrency?: number, sendingTimeout?: number, renewClaim?: boolean | number, maxRenewals?: number, strictPayload?: boolean, allowedMailFields?: string[], verifyTransports?: boolean, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, onError?: (error: unknown, email: MailTimeTask | null, details?: object) => void, onSent?: (email: MailTimeTask, details?: object) => void }} MailTimeOptions
|
|
2156
2555
|
*/
|
|
2157
2556
|
|
|
2158
2557
|
/** Class of MailTime */
|
|
@@ -2213,7 +2612,34 @@ class MailTime {
|
|
|
2213
2612
|
this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
|
|
2214
2613
|
this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
|
|
2215
2614
|
this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
|
|
2216
|
-
this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
|
|
2615
|
+
this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && Number.isFinite(opts.sendingTimeout) && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
|
|
2616
|
+
if (this.sendingTimeout < MIN_SAFE_SENDING_TIMEOUT) {
|
|
2617
|
+
logError(`{sendingTimeout: ${this.sendingTimeout}} is below the ${MIN_SAFE_SENDING_TIMEOUT}ms safe floor — a claim that expires while SMTP is still in flight is re-claimed by a peer and the letter is delivered twice. Raise {sendingTimeout} above the worst-case SMTP roundtrip.`);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// Claim renewal: MailTime has no way to know how long a transport will take, so a
|
|
2621
|
+
// long-but-healthy send used to lose its lock to a "recovery" worker. While a send
|
|
2622
|
+
// is in flight the claim is re-stamped every {renewClaim} ms — bounded by
|
|
2623
|
+
// {maxRenewals} so a genuinely wedged send is still recovered, just later.
|
|
2624
|
+
if (opts.renewClaim === false || opts.renewClaim === 0) {
|
|
2625
|
+
this.renewClaim = 0;
|
|
2626
|
+
} else if (typeof opts.renewClaim === 'number' && Number.isFinite(opts.renewClaim) && opts.renewClaim > 0) {
|
|
2627
|
+
this.renewClaim = Math.floor(opts.renewClaim);
|
|
2628
|
+
} else {
|
|
2629
|
+
this.renewClaim = Math.max(1000, Math.floor(this.sendingTimeout / 3));
|
|
2630
|
+
}
|
|
2631
|
+
this.maxRenewals = (typeof opts.maxRenewals === 'number' && Number.isFinite(opts.maxRenewals) && opts.maxRenewals >= 0) ? Math.floor(opts.maxRenewals) : DEFAULT_MAX_RENEWALS;
|
|
2632
|
+
|
|
2633
|
+
// Transport fail-over policy. Rotating on *any* error can resubmit a letter the
|
|
2634
|
+
// MX already accepted; a transport that knows better says so.
|
|
2635
|
+
this.shouldFailOver = (typeof opts.shouldFailOver === 'function') ? opts.shouldFailOver.bind(this) : null;
|
|
2636
|
+
|
|
2637
|
+
this.strictPayload = opts.strictPayload === true;
|
|
2638
|
+
this.allowedMailFields = new Set([
|
|
2639
|
+
...STRICT_MAIL_FIELDS,
|
|
2640
|
+
...(Array.isArray(opts.allowedMailFields) ? opts.allowedMailFields.filter((field) => typeof field === 'string') : []),
|
|
2641
|
+
]);
|
|
2642
|
+
|
|
2217
2643
|
this.__isDestroyed = false;
|
|
2218
2644
|
this.__abortInFlight = false;
|
|
2219
2645
|
this.__isPaused = false;
|
|
@@ -2272,6 +2698,8 @@ class MailTime {
|
|
|
2272
2698
|
this.__debug(`INITIALIZING [mode: ${this.mode}]`);
|
|
2273
2699
|
this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
|
|
2274
2700
|
this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
|
|
2701
|
+
this.__debug(`INITIALIZING [renewClaim: ${this.renewClaim}]`);
|
|
2702
|
+
this.__debug(`INITIALIZING [strictPayload: ${this.strictPayload}]`);
|
|
2275
2703
|
|
|
2276
2704
|
/** SERVER-SPECIFIC CHECKS AND CONFIG */
|
|
2277
2705
|
if (this.type === 'server') {
|
|
@@ -2336,6 +2764,7 @@ class MailTime {
|
|
|
2336
2764
|
};
|
|
2337
2765
|
}
|
|
2338
2766
|
|
|
2767
|
+
/** @type {MailTimeScheduler | undefined} */
|
|
2339
2768
|
this.scheduler = new josk.JoSk({
|
|
2340
2769
|
debug: this.debug,
|
|
2341
2770
|
...this.josk,
|
|
@@ -2501,12 +2930,17 @@ class MailTime {
|
|
|
2501
2930
|
* @returns {Promise<string>} uuid of the email
|
|
2502
2931
|
* @throws {Error}
|
|
2503
2932
|
*/
|
|
2504
|
-
async sendMail(opts
|
|
2933
|
+
async sendMail(opts) {
|
|
2934
|
+
opts = (opts && typeof opts === 'object') ? opts : {};
|
|
2505
2935
|
this.__debug('[sendMail]', opts);
|
|
2506
2936
|
if (!opts.html && !opts.text) {
|
|
2507
2937
|
throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
|
|
2508
2938
|
}
|
|
2509
2939
|
|
|
2940
|
+
if (opts.raw !== void 0) {
|
|
2941
|
+
throw new Error('[mail-time] [sendMail] `raw` is not supported: it bypasses composition, so `template`, `concatEmails`, and the `from()` callback would silently stop applying — and nodemailer historically let a message-level `raw` bypass `disableFileAccess`/`disableUrlAccess`. Pass `html` / `text` instead.');
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2510
2944
|
let sendAt = opts.sendAt;
|
|
2511
2945
|
if (sendAt instanceof Date) {
|
|
2512
2946
|
sendAt = +sendAt;
|
|
@@ -2635,13 +3069,16 @@ class MailTime {
|
|
|
2635
3069
|
task.isSending = false;
|
|
2636
3070
|
|
|
2637
3071
|
this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
|
|
2638
|
-
this.onError
|
|
3072
|
+
callHook('onError', this.onError, error, task, info);
|
|
2639
3073
|
return;
|
|
2640
3074
|
}
|
|
2641
3075
|
|
|
2642
3076
|
let transportIndex = task.transport;
|
|
2643
3077
|
|
|
2644
|
-
if (this.strategy === 'backup'
|
|
3078
|
+
if (this.strategy === 'backup'
|
|
3079
|
+
&& this.transports.length > 1
|
|
3080
|
+
&& (task.tries % this.failsToNext) === 0
|
|
3081
|
+
&& this.___mayFailOver(error, info, task)) {
|
|
2645
3082
|
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
2646
3083
|
}
|
|
2647
3084
|
|
|
@@ -2703,12 +3140,18 @@ class MailTime {
|
|
|
2703
3140
|
* @internal
|
|
2704
3141
|
* @memberOf MailTime
|
|
2705
3142
|
* @name ___render
|
|
2706
|
-
* @description Render Mustache-like placeholders
|
|
3143
|
+
* @description Render Mustache-like placeholders. `{{{key}}}` is always verbatim.
|
|
3144
|
+
* `{{key}}` is HTML-escaped in HTML contexts (`html`, `template`, `concatDelimiter`) —
|
|
3145
|
+
* that is what double-brace means everywhere else, and the previous strip-tags pass was
|
|
3146
|
+
* not a safety net: it needs a closing `>`, so an unterminated `<a href="…` survived
|
|
3147
|
+
* into the rendered body. Text bodies and subject headers are not HTML, so `{{key}}`
|
|
3148
|
+
* substitutes verbatim there.
|
|
2707
3149
|
* @param {string} _string - Template with Mustache-like placeholders
|
|
2708
3150
|
* @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
|
|
3151
|
+
* @param {boolean} [isHtml=true] - `false` for text/plain bodies and header values
|
|
2709
3152
|
* @returns {string}
|
|
2710
3153
|
*/
|
|
2711
|
-
___render(_string, replacements) {
|
|
3154
|
+
___render(_string, replacements, isHtml = true) {
|
|
2712
3155
|
let string = _string;
|
|
2713
3156
|
const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
|
|
2714
3157
|
if (matchHTML) {
|
|
@@ -2725,7 +3168,8 @@ class MailTime {
|
|
|
2725
3168
|
for (let i = 0; i < matchStr.length; i++) {
|
|
2726
3169
|
const key = matchStr[i].slice(2, -2).trim();
|
|
2727
3170
|
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
2728
|
-
|
|
3171
|
+
const value = `${replacements[key]}`;
|
|
3172
|
+
string = string.replace(matchStr[i], isHtml ? escapeHtml(value) : value);
|
|
2729
3173
|
}
|
|
2730
3174
|
}
|
|
2731
3175
|
}
|
|
@@ -2764,7 +3208,7 @@ class MailTime {
|
|
|
2764
3208
|
const isMulti = mailOptionsList.length > 1;
|
|
2765
3209
|
|
|
2766
3210
|
for (let i = 0; i < mailOptionsList.length; i++) {
|
|
2767
|
-
const mailOption = { ...mailOptionsList[i] };
|
|
3211
|
+
const mailOption = this.___applyPayloadPolicy({ ...mailOptionsList[i] });
|
|
2768
3212
|
|
|
2769
3213
|
if (mailOption.html) {
|
|
2770
3214
|
const rendered = this.___render(mailOption.html, mailOption);
|
|
@@ -2777,7 +3221,7 @@ class MailTime {
|
|
|
2777
3221
|
}
|
|
2778
3222
|
|
|
2779
3223
|
if (mailOption.text) {
|
|
2780
|
-
const rendered = this.___render(mailOption.text, mailOption);
|
|
3224
|
+
const rendered = this.___render(mailOption.text, mailOption, false);
|
|
2781
3225
|
if (isMulti) {
|
|
2782
3226
|
compiledOpts.text += '\r\n' + rendered;
|
|
2783
3227
|
} else {
|
|
@@ -2795,11 +3239,14 @@ class MailTime {
|
|
|
2795
3239
|
|
|
2796
3240
|
if (isMulti) {
|
|
2797
3241
|
const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
|
|
2798
|
-
compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
|
|
3242
|
+
compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length }, false);
|
|
2799
3243
|
}
|
|
2800
3244
|
|
|
2801
3245
|
if (!compiledOpts.from && this.from) {
|
|
2802
|
-
compiledOpts.from = this.from(transport
|
|
3246
|
+
compiledOpts.from = this.from(transport, {
|
|
3247
|
+
index: this.transports.indexOf(transport),
|
|
3248
|
+
from: MailTime.transportFrom(transport),
|
|
3249
|
+
});
|
|
2803
3250
|
}
|
|
2804
3251
|
|
|
2805
3252
|
const acceptedSet = collectAcceptedSet(task);
|
|
@@ -2809,9 +3256,84 @@ class MailTime {
|
|
|
2809
3256
|
compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
|
|
2810
3257
|
}
|
|
2811
3258
|
|
|
3259
|
+
if (this.strictPayload) {
|
|
3260
|
+
compiledOpts.disableFileAccess = true;
|
|
3261
|
+
compiledOpts.disableUrlAccess = true;
|
|
3262
|
+
}
|
|
3263
|
+
|
|
2812
3264
|
return compiledOpts;
|
|
2813
3265
|
}
|
|
2814
3266
|
|
|
3267
|
+
/**
|
|
3268
|
+
* @internal
|
|
3269
|
+
* @memberOf MailTime
|
|
3270
|
+
* @name ___applyPayloadPolicy
|
|
3271
|
+
* @description Narrow one queued mail option to what a task is allowed to set. `raw` is
|
|
3272
|
+
* always dropped: it bypasses composition (so `template`, `concatEmails` and the
|
|
3273
|
+
* `from()` callback silently stop applying) and historically bypassed nodemailer's
|
|
3274
|
+
* `disableFileAccess`/`disableUrlAccess` with it. Under `{strictPayload: true}` only
|
|
3275
|
+
* `allowedMailFields` survive.
|
|
3276
|
+
* @param {MailTimeMailOptions} mailOption - a single queued mail option object
|
|
3277
|
+
* @returns {MailTimeMailOptions}
|
|
3278
|
+
*/
|
|
3279
|
+
___applyPayloadPolicy(mailOption) {
|
|
3280
|
+
if (mailOption.raw !== void 0) {
|
|
3281
|
+
delete mailOption.raw;
|
|
3282
|
+
logError('[___compileMailOpts] dropped {raw} from a queued task — `raw` bypasses composition and is never sent by MailTime');
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
if (!this.strictPayload) {
|
|
3286
|
+
return mailOption;
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
const allowed = {};
|
|
3290
|
+
for (const key of Object.keys(mailOption)) {
|
|
3291
|
+
if (this.allowedMailFields.has(key)) {
|
|
3292
|
+
allowed[key] = mailOption[key];
|
|
3293
|
+
} else {
|
|
3294
|
+
this.__debug('[___applyPayloadPolicy] dropped non-allowlisted field', key);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
return allowed;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
/**
|
|
3301
|
+
* @static
|
|
3302
|
+
* @memberOf MailTime
|
|
3303
|
+
* @name transportFrom
|
|
3304
|
+
* @description Best-effort sender address for a transport. `nodemailer.createTransport()`
|
|
3305
|
+
* only exposes `.options` for *plain-object* configs — for a class-instance transporter
|
|
3306
|
+
* (anything with its own `.send()`) nodemailer's internal `Mail.options` is always `{}`,
|
|
3307
|
+
* so `transport.options.from` silently reads `undefined`. This walks the places the
|
|
3308
|
+
* address can actually live.
|
|
3309
|
+
* @param {MailTimeTransport} transport
|
|
3310
|
+
* @returns {string | undefined}
|
|
3311
|
+
*/
|
|
3312
|
+
static transportFrom(transport) {
|
|
3313
|
+
const candidates = [
|
|
3314
|
+
transport?.options?.from,
|
|
3315
|
+
transport?.transporter?.options?.from,
|
|
3316
|
+
transport?._defaults?.from,
|
|
3317
|
+
transport?._options?.from,
|
|
3318
|
+
];
|
|
3319
|
+
|
|
3320
|
+
for (const candidate of candidates) {
|
|
3321
|
+
// nodemailer accepts `from` as a string or as `{ name, address }`. Resolve the
|
|
3322
|
+
// first candidate that yields a usable address — not the first truthy slot, so a
|
|
3323
|
+
// malformed `{ name }` object cannot mask a real address further down the chain.
|
|
3324
|
+
const address = (typeof candidate === 'string')
|
|
3325
|
+
? candidate
|
|
3326
|
+
: (candidate && typeof candidate === 'object' && typeof candidate.address === 'string')
|
|
3327
|
+
? candidate.address
|
|
3328
|
+
: '';
|
|
3329
|
+
if (address.trim()) {
|
|
3330
|
+
return address;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
return void 0;
|
|
3335
|
+
}
|
|
3336
|
+
|
|
2815
3337
|
/**
|
|
2816
3338
|
* @internal
|
|
2817
3339
|
* @memberOf MailTime
|
|
@@ -2876,6 +3398,124 @@ class MailTime {
|
|
|
2876
3398
|
}
|
|
2877
3399
|
}
|
|
2878
3400
|
|
|
3401
|
+
/**
|
|
3402
|
+
* @internal
|
|
3403
|
+
* @memberOf MailTime
|
|
3404
|
+
* @name ___mayFailOver
|
|
3405
|
+
* @description Whether this failure may be retried on the *next* transport. Rotating on
|
|
3406
|
+
* any error can resubmit a letter the receiving MTA already accepted (or may have
|
|
3407
|
+
* accepted — a socket that died during `DATA` is indistinguishable from one that died
|
|
3408
|
+
* before it). A transport signals "do not fail over" with `error.mayFailOver = false`;
|
|
3409
|
+
* the `shouldFailOver(error, info, task)` option overrides both.
|
|
3410
|
+
* @param {unknown} error - error returned by the transport
|
|
3411
|
+
* @param {object|undefined} info - info object returned by the transport
|
|
3412
|
+
* @param {MailTimeTask} task - email's task object from Storage
|
|
3413
|
+
* @returns {boolean}
|
|
3414
|
+
*/
|
|
3415
|
+
___mayFailOver(error, info, task) {
|
|
3416
|
+
if (this.shouldFailOver) {
|
|
3417
|
+
try {
|
|
3418
|
+
return this.shouldFailOver(error, info, task) !== false;
|
|
3419
|
+
} catch (policyError) {
|
|
3420
|
+
logError('[shouldFailOver] callback failed; keeping the current transport', policyError);
|
|
3421
|
+
return false;
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
return !(error && typeof error === 'object' && error.mayFailOver === false);
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
/**
|
|
3428
|
+
* @internal
|
|
3429
|
+
* @memberOf MailTime
|
|
3430
|
+
* @name ___startClaimRenewal
|
|
3431
|
+
* @description Hold a claim alive while its SMTP roundtrip runs. MailTime 4.x stamped
|
|
3432
|
+
* `sendingAt` once at claim time, so any send slower than `sendingTimeout` lost its lock
|
|
3433
|
+
* to a "recovery" worker and the letter went out twice. The renewal is a lease-guarded
|
|
3434
|
+
* update — it succeeds only while *this* worker still owns the row — and is bounded by
|
|
3435
|
+
* `maxRenewals` so a genuinely wedged send is still recovered.
|
|
3436
|
+
* @param {MailTimeTask} task - claimed task, with `isSending`/`sendingAt`/`tries` set
|
|
3437
|
+
* @returns {{ stop: () => Promise<void> }}
|
|
3438
|
+
*/
|
|
3439
|
+
___startClaimRenewal(task) {
|
|
3440
|
+
if (!this.renewClaim || !this.maxRenewals || this.type !== 'server') {
|
|
3441
|
+
return { stop: async () => {} };
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
let stopped = false;
|
|
3445
|
+
let renewals = 0;
|
|
3446
|
+
let timer = null;
|
|
3447
|
+
let activeRenewal = null;
|
|
3448
|
+
const halt = () => {
|
|
3449
|
+
if (stopped) {
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
stopped = true;
|
|
3453
|
+
if (timer) {
|
|
3454
|
+
clearInterval(timer);
|
|
3455
|
+
timer = null;
|
|
3456
|
+
}
|
|
3457
|
+
};
|
|
3458
|
+
const stop = async () => {
|
|
3459
|
+
halt();
|
|
3460
|
+
if (activeRenewal) {
|
|
3461
|
+
await activeRenewal;
|
|
3462
|
+
}
|
|
3463
|
+
};
|
|
3464
|
+
|
|
3465
|
+
const renew = async () => {
|
|
3466
|
+
if (stopped || this.__isDestroyed || this.__abortInFlight) {
|
|
3467
|
+
halt();
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
if (renewals >= this.maxRenewals) {
|
|
3472
|
+
this.__debug('[private renewClaim] renewal budget exhausted; letting the claim go stale', task.uuid);
|
|
3473
|
+
halt();
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3476
|
+
renewals += 1;
|
|
3477
|
+
|
|
3478
|
+
// Strictly greater than the current stamp: storages that report "modified" only on
|
|
3479
|
+
// an actual value change would otherwise read a same-millisecond renewal as a no-op.
|
|
3480
|
+
const renewedAt = Math.max(Date.now(), (typeof task.sendingAt === 'number' ? task.sendingAt : 0) + 1);
|
|
3481
|
+
let renewed = false;
|
|
3482
|
+
try {
|
|
3483
|
+
renewed = await this.queue.update(task, __withLease(task, {
|
|
3484
|
+
isSending: true,
|
|
3485
|
+
sendingAt: renewedAt,
|
|
3486
|
+
}));
|
|
3487
|
+
} catch (renewError) {
|
|
3488
|
+
logError('[private renewClaim] storage error during claim renewal', renewError);
|
|
3489
|
+
halt();
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
if (!renewed) {
|
|
3494
|
+
this.__debug('[private renewClaim] lease lost; stopping renewal', task.uuid);
|
|
3495
|
+
halt();
|
|
3496
|
+
return;
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
task.sendingAt = renewedAt;
|
|
3500
|
+
this.__debug(`[private renewClaim] renewed ${renewals}/${this.maxRenewals}`, task.uuid);
|
|
3501
|
+
};
|
|
3502
|
+
|
|
3503
|
+
timer = setInterval(() => {
|
|
3504
|
+
if (activeRenewal) {
|
|
3505
|
+
return;
|
|
3506
|
+
}
|
|
3507
|
+
activeRenewal = renew().finally(() => {
|
|
3508
|
+
activeRenewal = null;
|
|
3509
|
+
});
|
|
3510
|
+
}, this.renewClaim);
|
|
3511
|
+
|
|
3512
|
+
if (typeof timer.unref === 'function') {
|
|
3513
|
+
timer.unref();
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
return { stop };
|
|
3517
|
+
}
|
|
3518
|
+
|
|
2879
3519
|
/**
|
|
2880
3520
|
* @async
|
|
2881
3521
|
* @internal
|
|
@@ -2956,131 +3596,142 @@ class MailTime {
|
|
|
2956
3596
|
const transport = this.transports[transportIndex];
|
|
2957
3597
|
|
|
2958
3598
|
const compiledOpts = this.___compileMailOpts(transport, task);
|
|
3599
|
+
const renewal = this.___startClaimRenewal(task);
|
|
2959
3600
|
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
if (error) {
|
|
2970
|
-
await this.___handleError(task, error, info);
|
|
3601
|
+
try {
|
|
3602
|
+
await new Promise((resolve) => {
|
|
3603
|
+
transport.sendMail(compiledOpts, async (error, info) => {
|
|
3604
|
+
// The roundtrip has settled — stop extending the lease before any completion
|
|
3605
|
+
// write, so the guard below sees the last `sendingAt` this worker persisted.
|
|
3606
|
+
await renewal.stop();
|
|
3607
|
+
if (this.__abortInFlight) {
|
|
3608
|
+
this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
|
|
3609
|
+
resolve();
|
|
2971
3610
|
return;
|
|
2972
3611
|
}
|
|
3612
|
+
this.__debug('[private send] [sending]', { error, info });
|
|
3613
|
+
try {
|
|
3614
|
+
if (error) {
|
|
3615
|
+
await this.___handleError(task, error, info);
|
|
3616
|
+
return;
|
|
3617
|
+
}
|
|
2973
3618
|
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
3619
|
+
const acceptedAddrs = Array.isArray(info?.accepted)
|
|
3620
|
+
? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
|
|
3621
|
+
: [];
|
|
2977
3622
|
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
3623
|
+
if (acceptedAddrs.length === 0) {
|
|
3624
|
+
await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
2982
3627
|
|
|
2983
|
-
|
|
3628
|
+
this.___trackAcceptedRecipients(task, acceptedAddrs);
|
|
2984
3629
|
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
3630
|
+
const allRecipients = collectAllRecipients(task);
|
|
3631
|
+
const allAccepted = collectAcceptedSet(task);
|
|
3632
|
+
let isFullyDelivered = true;
|
|
3633
|
+
for (const addr of allRecipients) {
|
|
3634
|
+
if (!allAccepted.has(addr)) {
|
|
3635
|
+
isFullyDelivered = false;
|
|
3636
|
+
break;
|
|
3637
|
+
}
|
|
2992
3638
|
}
|
|
2993
|
-
}
|
|
2994
|
-
|
|
2995
|
-
if (isFullyDelivered) {
|
|
2996
|
-
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
2997
3639
|
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3640
|
+
if (isFullyDelivered) {
|
|
3641
|
+
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
3642
|
+
|
|
3643
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
3644
|
+
const leaseUpdate = __withLease(task, {
|
|
3645
|
+
isSent: true,
|
|
3646
|
+
isSending: false,
|
|
3647
|
+
sendingAt: 0,
|
|
3648
|
+
mailOptions: task.mailOptions,
|
|
3649
|
+
});
|
|
3650
|
+
|
|
3651
|
+
let completed = false;
|
|
3652
|
+
if (!this.keepHistory) {
|
|
3653
|
+
completed = await this.queue.remove(task, leaseRemove);
|
|
3654
|
+
} else {
|
|
3655
|
+
completed = await this.queue.update(task, leaseUpdate);
|
|
3656
|
+
}
|
|
3005
3657
|
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
completed = await this.queue.update(task, leaseUpdate);
|
|
3011
|
-
}
|
|
3658
|
+
if (!completed) {
|
|
3659
|
+
this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
|
|
3660
|
+
return;
|
|
3661
|
+
}
|
|
3012
3662
|
|
|
3013
|
-
|
|
3014
|
-
|
|
3663
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
|
|
3664
|
+
task.isSent = true;
|
|
3665
|
+
task.isSending = false;
|
|
3666
|
+
task.sendingAt = 0;
|
|
3667
|
+
callHook('onSent', this.onSent, task, info);
|
|
3015
3668
|
return;
|
|
3016
3669
|
}
|
|
3017
3670
|
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3671
|
+
if (task.tries >= this.maxTries) {
|
|
3672
|
+
this.___finalizeRejected(task, info);
|
|
3673
|
+
|
|
3674
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
3675
|
+
const leaseUpdate = __withLease(task, {
|
|
3676
|
+
isSent: false,
|
|
3677
|
+
isFailed: true,
|
|
3678
|
+
isSending: false,
|
|
3679
|
+
sendingAt: 0,
|
|
3680
|
+
mailOptions: task.mailOptions,
|
|
3681
|
+
});
|
|
3682
|
+
|
|
3683
|
+
let finalized = false;
|
|
3684
|
+
if (!this.keepHistory) {
|
|
3685
|
+
finalized = await this.queue.remove(task, leaseRemove);
|
|
3686
|
+
} else {
|
|
3687
|
+
finalized = await this.queue.update(task, leaseUpdate);
|
|
3688
|
+
}
|
|
3028
3689
|
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
isSending: false,
|
|
3034
|
-
sendingAt: 0,
|
|
3035
|
-
mailOptions: task.mailOptions,
|
|
3036
|
-
});
|
|
3690
|
+
if (!finalized) {
|
|
3691
|
+
this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3037
3694
|
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
3043
|
-
}
|
|
3695
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
|
|
3696
|
+
task.isSent = false;
|
|
3697
|
+
task.isFailed = true;
|
|
3698
|
+
task.isSending = false;
|
|
3044
3699
|
|
|
3045
|
-
|
|
3046
|
-
|
|
3700
|
+
const rejectedAddrs = [];
|
|
3701
|
+
for (const mo of task.mailOptions) {
|
|
3702
|
+
for (const r of (mo.rejected || [])) {
|
|
3703
|
+
rejectedAddrs.push(r.address);
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
|
|
3707
|
+
this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
|
|
3708
|
+
callHook('onError', this.onError, partialError, task, info);
|
|
3047
3709
|
return;
|
|
3048
3710
|
}
|
|
3049
3711
|
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3712
|
+
const nextSendAt = Date.now() + this.retryDelay;
|
|
3713
|
+
const released = await this.queue.update(task, __withLease(task, {
|
|
3714
|
+
isSending: false,
|
|
3715
|
+
sendingAt: 0,
|
|
3716
|
+
sendAt: nextSendAt,
|
|
3717
|
+
mailOptions: task.mailOptions,
|
|
3718
|
+
}));
|
|
3719
|
+
if (!released) {
|
|
3720
|
+
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
3721
|
+
return;
|
|
3060
3722
|
}
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3723
|
+
this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
|
|
3724
|
+
} catch (completionError) {
|
|
3725
|
+
logError('[private send] completion error after transport callback', completionError);
|
|
3726
|
+
} finally {
|
|
3727
|
+
resolve();
|
|
3065
3728
|
}
|
|
3066
|
-
|
|
3067
|
-
const nextSendAt = Date.now() + this.retryDelay;
|
|
3068
|
-
const released = await this.queue.update(task, __withLease(task, {
|
|
3069
|
-
isSending: false,
|
|
3070
|
-
sendingAt: 0,
|
|
3071
|
-
sendAt: nextSendAt,
|
|
3072
|
-
mailOptions: task.mailOptions,
|
|
3073
|
-
}));
|
|
3074
|
-
if (!released) {
|
|
3075
|
-
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
3076
|
-
return;
|
|
3077
|
-
}
|
|
3078
|
-
this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
|
|
3079
|
-
} finally {
|
|
3080
|
-
resolve();
|
|
3081
|
-
}
|
|
3729
|
+
});
|
|
3082
3730
|
});
|
|
3083
|
-
}
|
|
3731
|
+
} finally {
|
|
3732
|
+
// A transport that throws synchronously never reaches the callback above.
|
|
3733
|
+
await renewal.stop();
|
|
3734
|
+
}
|
|
3084
3735
|
} catch (e) {
|
|
3085
3736
|
if (this.__abortInFlight) {
|
|
3086
3737
|
this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
|
|
@@ -3167,7 +3818,7 @@ class MailTime {
|
|
|
3167
3818
|
}
|
|
3168
3819
|
this.__unhealthyTransports.add(r.index);
|
|
3169
3820
|
logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
|
|
3170
|
-
this.onError
|
|
3821
|
+
callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
|
|
3171
3822
|
}
|
|
3172
3823
|
|
|
3173
3824
|
if (this.__unhealthyTransports.size === this.transports.length) {
|