mail-time 3.0.0 → 4.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/LICENSE +1 -1
- package/README.md +381 -607
- package/adapters/mongo.d.ts +118 -0
- package/adapters/mongo.js +204 -80
- package/adapters/postgres.d.ts +100 -0
- package/adapters/postgres.js +536 -0
- package/adapters/redis.d.ts +105 -0
- package/adapters/redis.js +342 -79
- package/helpers.js +286 -3
- package/index.cjs +2395 -457
- package/index.d.cts +322 -0
- package/index.d.ts +322 -0
- package/index.js +886 -303
- package/package.json +78 -17
- package/presets.d.ts +87 -0
- package/presets.js +193 -0
- package/adapters/blank-example.js +0 -230
package/index.cjs
CHANGED
|
@@ -1,51 +1,335 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const josk = require('josk');
|
|
4
|
-
const merge = require('deepmerge');
|
|
5
4
|
const crypto = require('crypto');
|
|
6
5
|
|
|
6
|
+
const hasOwn = Object.prototype.hasOwnProperty;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @name hasOwnProp - `Object.hasOwn` (ES2022) polyfill.
|
|
10
|
+
* @function
|
|
11
|
+
* @param {object} obj
|
|
12
|
+
* @param {PropertyKey} key
|
|
13
|
+
* @returns {boolean} `true` if the object has the property, `false` otherwise.
|
|
14
|
+
*/
|
|
15
|
+
const hasOwnProp = (obj, key) => hasOwn.call(obj, key);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @name debug - Debug logging.
|
|
19
|
+
* @function
|
|
20
|
+
* @param {boolean} isDebug
|
|
21
|
+
* @param {...any} args
|
|
22
|
+
* @returns {void}
|
|
23
|
+
*/
|
|
7
24
|
const debug = (isDebug, ...args) => {
|
|
8
25
|
if (isDebug) {
|
|
9
|
-
console.info
|
|
26
|
+
console.info('[DEBUG] [mail-time]', `${new Date()}`, ...args);
|
|
10
27
|
}
|
|
11
28
|
};
|
|
12
29
|
|
|
30
|
+
/**
|
|
31
|
+
* @name logError - Error logging.
|
|
32
|
+
* @function
|
|
33
|
+
* @param {...any} args
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
13
36
|
const logError = (...args) => {
|
|
14
|
-
console.error
|
|
37
|
+
console.error('[ERROR] [mail-time]', `${new Date()}`, ...args);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @name isPlainObject - Check whether a value is a plain object (literal or `Object.create(null)`).
|
|
42
|
+
* @function
|
|
43
|
+
* @param {any} value
|
|
44
|
+
* @returns {boolean} `true` for plain objects, `false` for `null`, arrays, class instances, and primitives.
|
|
45
|
+
*/
|
|
46
|
+
const isPlainObject = (value) => {
|
|
47
|
+
if (value === null || typeof value !== 'object') {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const proto = Object.getPrototypeOf(value);
|
|
51
|
+
return proto === null || proto === Object.prototype;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @name deepMerge - Minimal deep-merge sufficient for nodemailer-shaped mail options:
|
|
56
|
+
* - plain objects merge key-by-key
|
|
57
|
+
* - arrays concatenate
|
|
58
|
+
* - other values (strings, numbers, Date, Buffer, streams, classes) replace
|
|
59
|
+
* Source values override target values.
|
|
60
|
+
* @function
|
|
61
|
+
* @param {any} target - Base value; merged into a shallow clone when a plain object, otherwise ignored.
|
|
62
|
+
* @param {any} source - Overriding value; returns `target` unchanged when not a plain object.
|
|
63
|
+
* @returns {any} The merged result (a new object), or `target` when `source` is not a plain object.
|
|
64
|
+
*/
|
|
65
|
+
const deepMerge = (target, source) => {
|
|
66
|
+
if (!isPlainObject(source)) {
|
|
67
|
+
return target;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const out = isPlainObject(target) ? { ...target } : {};
|
|
71
|
+
|
|
72
|
+
for (const key of Object.keys(source)) {
|
|
73
|
+
const sVal = source[key];
|
|
74
|
+
const tVal = out[key];
|
|
75
|
+
|
|
76
|
+
if (Array.isArray(sVal)) {
|
|
77
|
+
out[key] = Array.isArray(tVal) ? tVal.concat(sVal) : sVal.slice();
|
|
78
|
+
} else if (isPlainObject(sVal)) {
|
|
79
|
+
out[key] = isPlainObject(tVal) ? deepMerge(tVal, sVal) : deepMerge({}, sVal);
|
|
80
|
+
} else {
|
|
81
|
+
out[key] = sVal;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return out;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @name equals - Order-insensitive deep equality. Treats arrays as multisets and
|
|
90
|
+
* objects as unordered maps. Designed for the small `mailOptions`
|
|
91
|
+
* shape used by MailTime's email concatenation dedup.
|
|
92
|
+
* @function
|
|
93
|
+
* @param {any} a
|
|
94
|
+
* @param {any} b
|
|
95
|
+
* @returns {boolean} `true` when `a` and `b` are deeply equal ignoring array/key order.
|
|
96
|
+
*/
|
|
97
|
+
const equals = (a, b) => {
|
|
98
|
+
if (a === b) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (a instanceof Date && b instanceof Date) {
|
|
107
|
+
return a.valueOf() === b.valueOf();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (Array.isArray(a)) {
|
|
111
|
+
if (!Array.isArray(b) || a.length !== b.length) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const matched = new Array(b.length).fill(false);
|
|
116
|
+
for (let i = 0; i < a.length; i++) {
|
|
117
|
+
let found = false;
|
|
118
|
+
for (let j = 0; j < b.length; j++) {
|
|
119
|
+
if (!matched[j] && equals(a[i], b[j])) {
|
|
120
|
+
matched[j] = true;
|
|
121
|
+
found = true;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!found) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (Array.isArray(b)) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const aKeys = Object.keys(a);
|
|
137
|
+
const bKeys = Object.keys(b);
|
|
138
|
+
|
|
139
|
+
if (aKeys.length !== bKeys.length) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < aKeys.length; i++) {
|
|
144
|
+
const key = aKeys[i];
|
|
145
|
+
if (!hasOwn.call(b, key) || !equals(a[key], b[key])) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return true;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @name extractEmail - Extract the email part of a nodemailer-shaped recipient entry.
|
|
155
|
+
* Accepts `'a@x.com'`, `'Name <a@x.com>'`, or `{ name, address }`.
|
|
156
|
+
* @function
|
|
157
|
+
* @param {string|{name?: string, address?: string}|null|undefined} entry
|
|
158
|
+
* @returns {string|null} The address lowercased and trimmed, or `null` when none can be parsed.
|
|
159
|
+
*/
|
|
160
|
+
const extractEmail = (entry) => {
|
|
161
|
+
if (!entry) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (typeof entry === 'object' && typeof entry.address === 'string') {
|
|
165
|
+
return entry.address.trim().toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
if (typeof entry !== 'string') {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const angled = entry.match(/<([^>]+)>/);
|
|
171
|
+
return (angled ? angled[1] : entry).trim().toLowerCase();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @name toAddressList - Normalize a `to`/`cc`/`bcc` field into a flat list of lowercase addresses.
|
|
176
|
+
* @function
|
|
177
|
+
* @param {string|Array<string|{name?: string, address?: string}>|null|undefined} field
|
|
178
|
+
* @returns {string[]} Flat list of parsed lowercase addresses; empty when `field` is falsy or unparseable.
|
|
179
|
+
*/
|
|
180
|
+
const toAddressList = (field) => {
|
|
181
|
+
if (!field) {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(field)) {
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const entry of field) {
|
|
187
|
+
const addr = extractEmail(entry);
|
|
188
|
+
if (addr) {
|
|
189
|
+
out.push(addr);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
const single = extractEmail(field);
|
|
195
|
+
return single ? [single] : [];
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @name filterAddressField - Remove entries whose extracted address is in `acceptedSet` from a
|
|
200
|
+
* nodemailer `to`/`cc`/`bcc` field.
|
|
201
|
+
* @function
|
|
202
|
+
* @param {string|Array<string|{name?: string, address?: string}>|null|undefined} field
|
|
203
|
+
* @param {Set<string>} acceptedSet - Lowercase addresses to drop.
|
|
204
|
+
* @returns {string|Array|undefined} The filtered field, or `void 0` when the filtered array would be
|
|
205
|
+
* empty or the single string is dropped. Returns `field` unchanged when `acceptedSet` is empty.
|
|
206
|
+
*/
|
|
207
|
+
const filterAddressField = (field, acceptedSet) => {
|
|
208
|
+
if (!field || !(acceptedSet instanceof Set) || acceptedSet.size === 0) {
|
|
209
|
+
return field;
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(field)) {
|
|
212
|
+
const filtered = [];
|
|
213
|
+
for (const entry of field) {
|
|
214
|
+
const addr = extractEmail(entry);
|
|
215
|
+
if (!addr || !acceptedSet.has(addr)) {
|
|
216
|
+
filtered.push(entry);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return filtered.length ? filtered : void 0;
|
|
220
|
+
}
|
|
221
|
+
const addr = extractEmail(field);
|
|
222
|
+
if (addr && acceptedSet.has(addr)) {
|
|
223
|
+
return void 0;
|
|
224
|
+
}
|
|
225
|
+
return field;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @name isSendClaimUpdate - Detect an atomic send-claim update (`{ isSending: true, tries: N }`).
|
|
230
|
+
* @function
|
|
231
|
+
* @param {object} updateObj
|
|
232
|
+
* @returns {boolean} `true` when `updateObj` claims a row for sending.
|
|
233
|
+
*/
|
|
234
|
+
const isSendClaimUpdate = (updateObj) => {
|
|
235
|
+
return updateObj && updateObj.isSending === true && typeof updateObj.tries === 'number';
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @name isSendLeaseGuardedUpdate - Detect an update guarded by a send lease
|
|
240
|
+
* (carries `leaseTries` and `leaseSendingAt`).
|
|
241
|
+
* @function
|
|
242
|
+
* @param {object} updateObj
|
|
243
|
+
* @returns {boolean} `true` when both lease guard fields are present numbers.
|
|
244
|
+
*/
|
|
245
|
+
const isSendLeaseGuardedUpdate = (updateObj) => {
|
|
246
|
+
return updateObj && typeof updateObj.leaseTries === 'number' && typeof updateObj.leaseSendingAt === 'number';
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @name isAppendMailOptionUpdate - Detect an update that appends a mail option (for email concatenation).
|
|
251
|
+
* @function
|
|
252
|
+
* @param {object} updateObj
|
|
253
|
+
* @returns {boolean} `true` when `updateObj.appendMailOption` is set.
|
|
254
|
+
*/
|
|
255
|
+
const isAppendMailOptionUpdate = (updateObj) => {
|
|
256
|
+
return updateObj && updateObj.appendMailOption !== void 0;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @name stripInternalUpdateMeta - Strip MailTime-internal update keys before persisting to storage.
|
|
261
|
+
* @function
|
|
262
|
+
* @param {object} updateObj
|
|
263
|
+
* @returns {object} A shallow clone without `leaseTries`, `leaseSendingAt`, and `appendMailOption`.
|
|
264
|
+
*/
|
|
265
|
+
const stripInternalUpdateMeta = (updateObj) => {
|
|
266
|
+
const out = { ...updateObj };
|
|
267
|
+
delete out.leaseTries;
|
|
268
|
+
delete out.leaseSendingAt;
|
|
269
|
+
delete out.appendMailOption;
|
|
270
|
+
return out;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @name isSendLeaseRemove - Detect a remove guarded by a send lease
|
|
275
|
+
* (carries `leaseTries` and `leaseSendingAt`).
|
|
276
|
+
* @function
|
|
277
|
+
* @param {object} opts
|
|
278
|
+
* @returns {boolean} `true` when both lease guard fields are present numbers.
|
|
279
|
+
*/
|
|
280
|
+
const isSendLeaseRemove = (opts) => {
|
|
281
|
+
return opts && typeof opts.leaseTries === 'number' && typeof opts.leaseSendingAt === 'number';
|
|
15
282
|
};
|
|
16
283
|
|
|
284
|
+
const DEFAULT_PREFIX$2 = '';
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @typedef {object} MongoCollection
|
|
288
|
+
* @property {string} [collectionName]
|
|
289
|
+
* @property {(keys: object, opts?: object) => Promise<unknown>} createIndex
|
|
290
|
+
* @property {() => Promise<{ name: string, key: Record<string, unknown> }[]>} indexes
|
|
291
|
+
* @property {(name: string) => Promise<unknown>} dropIndex
|
|
292
|
+
* @property {(query: object, opts?: object) => unknown} find
|
|
293
|
+
* @property {(query: object, opts?: object) => Promise<object|null>} findOne
|
|
294
|
+
* @property {(doc: object) => Promise<unknown>} insertOne
|
|
295
|
+
* @property {(query: object) => Promise<{ deletedCount?: number }>} deleteOne
|
|
296
|
+
* @property {(query: object, update: object) => Promise<{ modifiedCount?: number }>} updateOne
|
|
297
|
+
*/
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @typedef {object} Db
|
|
301
|
+
* @property {(name: string) => MongoCollection} collection
|
|
302
|
+
* @property {(cmd: object) => Promise<{ ok?: number }>} command
|
|
303
|
+
*/
|
|
304
|
+
|
|
17
305
|
/**
|
|
18
|
-
*
|
|
19
|
-
* @
|
|
20
|
-
* @
|
|
21
|
-
* @param {object} keys - Field and value pairs where the field is the index key and the value describes the type of index for that field
|
|
22
|
-
* @param {object} opts - Set of options that controls the creation of the index
|
|
23
|
-
* @returns {void 0}
|
|
306
|
+
* @typedef {object} MongoQueueOption
|
|
307
|
+
* @property {Db} db
|
|
308
|
+
* @property {string} [prefix]
|
|
24
309
|
*/
|
|
310
|
+
|
|
311
|
+
/** @internal */
|
|
25
312
|
const ensureIndex = async (collection, keys, opts) => {
|
|
26
313
|
try {
|
|
27
314
|
await collection.createIndex(keys, opts);
|
|
28
315
|
} catch (e) {
|
|
29
|
-
if (e
|
|
316
|
+
if (e?.code === 85) {
|
|
30
317
|
let indexName;
|
|
31
318
|
const indexes = await collection.indexes();
|
|
319
|
+
const keyNames = Object.keys(keys);
|
|
32
320
|
for (const index of indexes) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
drop = false;
|
|
37
|
-
break;
|
|
38
|
-
}
|
|
321
|
+
const indexKeys = Object.keys(index.key);
|
|
322
|
+
if (indexKeys.length !== keyNames.length) {
|
|
323
|
+
continue;
|
|
39
324
|
}
|
|
40
|
-
|
|
41
|
-
for (const
|
|
42
|
-
if (typeof
|
|
43
|
-
|
|
325
|
+
let match = true;
|
|
326
|
+
for (const k of keyNames) {
|
|
327
|
+
if (typeof index.key[k] === 'undefined') {
|
|
328
|
+
match = false;
|
|
44
329
|
break;
|
|
45
330
|
}
|
|
46
331
|
}
|
|
47
|
-
|
|
48
|
-
if (drop) {
|
|
332
|
+
if (match) {
|
|
49
333
|
indexName = index.name;
|
|
50
334
|
break;
|
|
51
335
|
}
|
|
@@ -56,7 +340,7 @@ const ensureIndex = async (collection, keys, opts) => {
|
|
|
56
340
|
await collection.createIndex(keys, opts);
|
|
57
341
|
}
|
|
58
342
|
} else {
|
|
59
|
-
logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection
|
|
343
|
+
logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection?.collectionName || 'MongoDB'}" collection`, { keys, opts, details: e });
|
|
60
344
|
}
|
|
61
345
|
}
|
|
62
346
|
};
|
|
@@ -65,47 +349,58 @@ const ensureIndex = async (collection, keys, opts) => {
|
|
|
65
349
|
class MongoQueue {
|
|
66
350
|
/**
|
|
67
351
|
* Create a MongoQueue instance
|
|
68
|
-
* @param {
|
|
69
|
-
* @param {Db} opts.db - Required, Mongo's `Db` instance, like one returned from `MongoClient#db()` method
|
|
70
|
-
* @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application
|
|
352
|
+
* @param {MongoQueueOption} opts - configuration object
|
|
71
353
|
*/
|
|
72
354
|
constructor (opts) {
|
|
73
355
|
this.name = 'mongo-queue';
|
|
74
|
-
if (!opts || typeof opts !== 'object'
|
|
356
|
+
if (!opts || typeof opts !== 'object') {
|
|
75
357
|
throw new TypeError('[mail-time] Configuration object must be passed into MongoQueue constructor');
|
|
76
358
|
}
|
|
77
359
|
|
|
78
360
|
if (!opts.db) {
|
|
79
|
-
throw new Error('[mail-time] [MongoQueue] requires MongoDB database {db} option, like returned from `MongoClient
|
|
361
|
+
throw new Error('[mail-time] [MongoQueue] requires MongoDB database {db} option, like returned from `MongoClient#db()`');
|
|
80
362
|
}
|
|
81
363
|
|
|
82
|
-
this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '';
|
|
83
364
|
this.db = opts.db;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
365
|
+
if (typeof opts.prefix === 'string') {
|
|
366
|
+
this.__applyPrefix(opts.prefix);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** @internal */
|
|
371
|
+
__applyPrefix(prefix) {
|
|
372
|
+
this.prefix = prefix;
|
|
373
|
+
this.collection = this.db.collection(`__mailTimeQueue__${prefix}`);
|
|
374
|
+
this.__readyPromise = Promise.all([
|
|
375
|
+
ensureIndex(this.collection, { uuid: 1 }, { background: false }),
|
|
376
|
+
ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, to: 1, sendAt: 1 }, { background: false }),
|
|
377
|
+
ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, isSending: 1, sendingAt: 1, sendAt: 1, tries: 1 }, { background: false }),
|
|
378
|
+
]).then(() => void 0);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** @internal */
|
|
382
|
+
__ensurePrefix() {
|
|
383
|
+
if (typeof this.prefix !== 'string') {
|
|
384
|
+
this.__applyPrefix(this.mailTimeInstance?.prefix || DEFAULT_PREFIX$2);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** @internal */
|
|
389
|
+
__debug(...args) {
|
|
390
|
+
debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @async
|
|
395
|
+
* @memberOf MongoQueue
|
|
396
|
+
* @name ready
|
|
397
|
+
* @description Wait until indexes are created
|
|
398
|
+
* @returns {Promise<void 0>}
|
|
399
|
+
*/
|
|
400
|
+
async ready() {
|
|
401
|
+
this.__ensurePrefix();
|
|
402
|
+
this.__debug('[ready]');
|
|
403
|
+
await this.__readyPromise;
|
|
109
404
|
}
|
|
110
405
|
|
|
111
406
|
/**
|
|
@@ -116,6 +411,7 @@ class MongoQueue {
|
|
|
116
411
|
* @returns {Promise<object>}
|
|
117
412
|
*/
|
|
118
413
|
async ping() {
|
|
414
|
+
this.__debug('[ping]');
|
|
119
415
|
if (!this.mailTimeInstance) {
|
|
120
416
|
return {
|
|
121
417
|
status: 'Service Unavailable',
|
|
@@ -139,7 +435,7 @@ class MongoQueue {
|
|
|
139
435
|
status: 'Internal Server Error',
|
|
140
436
|
code: 500,
|
|
141
437
|
statusCode: 500,
|
|
142
|
-
error: pingError
|
|
438
|
+
error: pingError,
|
|
143
439
|
};
|
|
144
440
|
}
|
|
145
441
|
|
|
@@ -147,28 +443,43 @@ class MongoQueue {
|
|
|
147
443
|
status: 'Service Unavailable',
|
|
148
444
|
code: 503,
|
|
149
445
|
statusCode: 503,
|
|
150
|
-
error: new Error('Service Unavailable')
|
|
446
|
+
error: new Error('Service Unavailable'),
|
|
151
447
|
};
|
|
152
448
|
}
|
|
153
449
|
|
|
154
450
|
/**
|
|
155
451
|
* @memberOf MongoQueue
|
|
156
452
|
* @name iterate
|
|
157
|
-
* @description iterate over queued tasks passing to `mailTimeInstance.
|
|
158
|
-
* @
|
|
453
|
+
* @description iterate over queued tasks passing each to `mailTimeInstance.___dispatch` (the bounded send pool)
|
|
454
|
+
* @param {{ limit?: number, sendingTimeout?: number }} [opts] - iteration options
|
|
455
|
+
* @returns {Promise<void>}
|
|
159
456
|
*/
|
|
160
|
-
async iterate() {
|
|
457
|
+
async iterate(opts) {
|
|
458
|
+
this.__debug('[iterate]', opts);
|
|
459
|
+
this.__ensurePrefix();
|
|
460
|
+
const now = Date.now();
|
|
461
|
+
const sendingTimeout = (opts && typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0)
|
|
462
|
+
? opts.sendingTimeout
|
|
463
|
+
: 300000;
|
|
464
|
+
const limit = (opts && typeof opts.limit === 'number' && Number.isFinite(opts.limit) && opts.limit > 0)
|
|
465
|
+
? Math.floor(opts.limit)
|
|
466
|
+
: 0;
|
|
467
|
+
|
|
161
468
|
try {
|
|
162
469
|
const cursor = this.collection.find({
|
|
163
470
|
isSent: false,
|
|
164
471
|
isFailed: false,
|
|
165
472
|
isCancelled: false,
|
|
166
473
|
sendAt: {
|
|
167
|
-
$lte:
|
|
474
|
+
$lte: now,
|
|
168
475
|
},
|
|
169
476
|
tries: {
|
|
170
|
-
$lt: this.mailTimeInstance.maxTries
|
|
171
|
-
}
|
|
477
|
+
$lt: this.mailTimeInstance.maxTries,
|
|
478
|
+
},
|
|
479
|
+
$or: [
|
|
480
|
+
{ isSending: { $ne: true } },
|
|
481
|
+
{ sendingAt: { $lte: now - sendingTimeout } },
|
|
482
|
+
],
|
|
172
483
|
}, {
|
|
173
484
|
projection: {
|
|
174
485
|
_id: 1,
|
|
@@ -179,15 +490,24 @@ class MongoQueue {
|
|
|
179
490
|
isSent: 1,
|
|
180
491
|
isFailed: 1,
|
|
181
492
|
isCancelled: 1,
|
|
493
|
+
isSending: 1,
|
|
494
|
+
sendingAt: 1,
|
|
182
495
|
mailOptions: 1,
|
|
183
496
|
concatSubject: 1,
|
|
184
|
-
}
|
|
497
|
+
},
|
|
185
498
|
});
|
|
186
499
|
|
|
187
|
-
|
|
188
|
-
|
|
500
|
+
if (limit > 0) {
|
|
501
|
+
cursor.limit(limit);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
while (await cursor.hasNext()) {
|
|
506
|
+
await this.mailTimeInstance.___dispatch(await cursor.next());
|
|
507
|
+
}
|
|
508
|
+
} finally {
|
|
509
|
+
await cursor.close().catch(() => {});
|
|
189
510
|
}
|
|
190
|
-
await cursor.close();
|
|
191
511
|
} catch (iterateError) {
|
|
192
512
|
logError('[iterate] [while/await] [iterateError]', iterateError);
|
|
193
513
|
}
|
|
@@ -203,18 +523,22 @@ class MongoQueue {
|
|
|
203
523
|
* @returns {Promise<object|null>}
|
|
204
524
|
*/
|
|
205
525
|
async getPendingTo(to, sendAt) {
|
|
526
|
+
this.__debug('[getPendingTo]', to, sendAt);
|
|
206
527
|
if (typeof to !== 'string' || typeof sendAt !== 'number') {
|
|
207
528
|
return null;
|
|
208
529
|
}
|
|
530
|
+
this.__ensurePrefix();
|
|
209
531
|
|
|
210
532
|
return await this.collection.findOne({
|
|
211
|
-
to
|
|
533
|
+
to,
|
|
212
534
|
isSent: false,
|
|
213
535
|
isFailed: false,
|
|
214
536
|
isCancelled: false,
|
|
537
|
+
isSending: { $ne: true },
|
|
538
|
+
tries: { $lt: this.mailTimeInstance.maxTries },
|
|
215
539
|
sendAt: {
|
|
216
|
-
$lte: sendAt
|
|
217
|
-
}
|
|
540
|
+
$lte: sendAt,
|
|
541
|
+
},
|
|
218
542
|
}, {
|
|
219
543
|
projection: {
|
|
220
544
|
_id: 1,
|
|
@@ -225,7 +549,7 @@ class MongoQueue {
|
|
|
225
549
|
isFailed: 1,
|
|
226
550
|
isCancelled: 1,
|
|
227
551
|
mailOptions: 1,
|
|
228
|
-
}
|
|
552
|
+
},
|
|
229
553
|
});
|
|
230
554
|
}
|
|
231
555
|
|
|
@@ -238,9 +562,11 @@ class MongoQueue {
|
|
|
238
562
|
* @returns {Promise<void 0>}
|
|
239
563
|
*/
|
|
240
564
|
async push(task) {
|
|
241
|
-
|
|
565
|
+
this.__debug('[push]', task?.uuid);
|
|
566
|
+
if (!task || typeof task !== 'object') {
|
|
242
567
|
return;
|
|
243
568
|
}
|
|
569
|
+
this.__ensurePrefix();
|
|
244
570
|
|
|
245
571
|
if (task.sendAt instanceof Date) {
|
|
246
572
|
task.sendAt = +task.sendAt;
|
|
@@ -257,9 +583,11 @@ class MongoQueue {
|
|
|
257
583
|
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
|
|
258
584
|
*/
|
|
259
585
|
async cancel(uuid) {
|
|
586
|
+
this.__debug('[cancel]', uuid);
|
|
260
587
|
if (typeof uuid !== 'string') {
|
|
261
588
|
return false;
|
|
262
589
|
}
|
|
590
|
+
this.__ensurePrefix();
|
|
263
591
|
|
|
264
592
|
const task = await this.collection.findOne({ uuid }, {
|
|
265
593
|
projection: {
|
|
@@ -267,7 +595,7 @@ class MongoQueue {
|
|
|
267
595
|
uuid: 1,
|
|
268
596
|
isSent: 1,
|
|
269
597
|
isCancelled: 1,
|
|
270
|
-
}
|
|
598
|
+
},
|
|
271
599
|
});
|
|
272
600
|
|
|
273
601
|
if (!task || task.isSent === true || task.isCancelled === true) {
|
|
@@ -289,49 +617,180 @@ class MongoQueue {
|
|
|
289
617
|
* @name remove
|
|
290
618
|
* @description remove task from queue
|
|
291
619
|
* @param task {object} - task's object
|
|
620
|
+
* @param {{ leaseTries: number, leaseSendingAt: number }} [opts] - lease guard: only remove if this worker still holds the lease (tries + sendingAt match, row not cancelled/failed)
|
|
292
621
|
* @returns {Promise<boolean>} returns `true` if removed or `false` if not found
|
|
293
622
|
*/
|
|
294
|
-
async remove(task) {
|
|
295
|
-
|
|
623
|
+
async remove(task, opts) {
|
|
624
|
+
this.__debug('[remove]', task?.uuid);
|
|
625
|
+
if (!task || typeof task !== 'object') {
|
|
296
626
|
return false;
|
|
297
627
|
}
|
|
628
|
+
this.__ensurePrefix();
|
|
298
629
|
|
|
299
|
-
|
|
630
|
+
const query = { _id: task._id };
|
|
631
|
+
if (isSendLeaseRemove(opts)) {
|
|
632
|
+
query.tries = opts.leaseTries;
|
|
633
|
+
query.isSending = true;
|
|
634
|
+
query.sendingAt = opts.leaseSendingAt;
|
|
635
|
+
query.isCancelled = false;
|
|
636
|
+
query.isFailed = false;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const res = await this.collection.deleteOne(query);
|
|
640
|
+
return (res?.deletedCount || 0) >= 1;
|
|
300
641
|
}
|
|
301
642
|
|
|
302
643
|
/**
|
|
303
644
|
* @async
|
|
304
645
|
* @memberOf MongoQueue
|
|
305
646
|
* @name update
|
|
306
|
-
* @description
|
|
647
|
+
* @description update task in queue
|
|
307
648
|
* @param task {object} - task's object
|
|
308
649
|
* @param updateObj {object} - fields with new values to update
|
|
309
650
|
* @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
|
|
310
651
|
*/
|
|
311
652
|
async update(task, updateObj) {
|
|
312
|
-
|
|
653
|
+
this.__debug('[update]', task?.uuid);
|
|
654
|
+
if (!task || typeof task !== 'object' || !updateObj || typeof updateObj !== 'object') {
|
|
313
655
|
return false;
|
|
314
656
|
}
|
|
657
|
+
this.__ensurePrefix();
|
|
315
658
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
659
|
+
if (isAppendMailOptionUpdate(updateObj)) {
|
|
660
|
+
const res = await this.collection.updateOne({
|
|
661
|
+
_id: task._id,
|
|
662
|
+
isSent: false,
|
|
663
|
+
isFailed: false,
|
|
664
|
+
isCancelled: false,
|
|
665
|
+
isSending: { $ne: true },
|
|
666
|
+
}, {
|
|
667
|
+
$push: {
|
|
668
|
+
mailOptions: updateObj.appendMailOption,
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
return (res?.modifiedCount || 0) >= 1;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const query = {
|
|
675
|
+
_id: task._id,
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
if (isSendClaimUpdate(updateObj)) {
|
|
679
|
+
const now = typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
|
|
680
|
+
const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
|
|
681
|
+
query.isSent = false;
|
|
682
|
+
query.isFailed = false;
|
|
683
|
+
query.isCancelled = false;
|
|
684
|
+
query.tries = task.tries;
|
|
685
|
+
query.$or = [
|
|
686
|
+
{ isSending: { $ne: true } },
|
|
687
|
+
{ sendingAt: { $lte: now - sendingTimeout } },
|
|
688
|
+
];
|
|
689
|
+
} else if (isSendLeaseGuardedUpdate(updateObj)) {
|
|
690
|
+
query.tries = updateObj.leaseTries;
|
|
691
|
+
query.isSending = true;
|
|
692
|
+
query.sendingAt = updateObj.leaseSendingAt;
|
|
693
|
+
query.isCancelled = false;
|
|
694
|
+
query.isFailed = false;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const res = await this.collection.updateOne(query, {
|
|
698
|
+
$set: stripInternalUpdateMeta(updateObj),
|
|
699
|
+
});
|
|
700
|
+
if (isSendClaimUpdate(updateObj)) {
|
|
701
|
+
return (res?.modifiedCount || res?.matchedCount || 0) >= 1;
|
|
702
|
+
}
|
|
703
|
+
return (res?.modifiedCount || 0) >= 1;
|
|
321
704
|
}
|
|
322
705
|
}
|
|
323
706
|
|
|
707
|
+
/**
|
|
708
|
+
* @typedef {object} RedisClient
|
|
709
|
+
* @property {(key: string) => Promise<number>} exists
|
|
710
|
+
* @property {(key: string) => Promise<string|null>} get
|
|
711
|
+
* @property {(key: string, value: string, options?: object) => Promise<unknown>} set
|
|
712
|
+
* @property {(key: string|string[]) => Promise<number>} del
|
|
713
|
+
* @property {() => Promise<string>} ping
|
|
714
|
+
* @property {(options: object) => AsyncIterable<string|string[]>} scanIterator
|
|
715
|
+
* @property {(key: string) => Promise<unknown>} [watch]
|
|
716
|
+
* @property {() => Promise<unknown>} [unwatch]
|
|
717
|
+
* @property {() => object} [multi]
|
|
718
|
+
*/
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* @typedef {object} RedisQueueOption
|
|
722
|
+
* @property {RedisClient} client
|
|
723
|
+
* @property {string} [prefix]
|
|
724
|
+
*/
|
|
725
|
+
|
|
726
|
+
const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
|
|
727
|
+
const DEFAULT_PREFIX$1 = 'default';
|
|
728
|
+
|
|
729
|
+
const canReleaseLease = (currentTask, updateObj) => {
|
|
730
|
+
return currentTask
|
|
731
|
+
&& currentTask.tries === updateObj.leaseTries
|
|
732
|
+
&& currentTask.isSending === true
|
|
733
|
+
&& (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) === updateObj.leaseSendingAt
|
|
734
|
+
&& currentTask.isCancelled !== true
|
|
735
|
+
&& currentTask.isFailed !== true;
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const canClaimTask = (currentTask, task, now, sendingTimeout) => {
|
|
739
|
+
if (!currentTask) {
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
if (currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
if (currentTask.tries !== task.tries) {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
if (currentTask.isSending === true) {
|
|
749
|
+
const sendingAt = typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0;
|
|
750
|
+
if (sendingAt > now - sendingTimeout) {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return true;
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
const isIterateCandidate = (candidate, now, sendingTimeout, maxTries) => {
|
|
758
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
if (candidate.isSent === true || candidate.isFailed === true || candidate.isCancelled === true) {
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
const tries = typeof candidate.tries === 'number' ? candidate.tries : 0;
|
|
765
|
+
if (tries >= maxTries) {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
if (candidate.isSending === true) {
|
|
769
|
+
const sendingAt = typeof candidate.sendingAt === 'number' ? candidate.sendingAt : 0;
|
|
770
|
+
if (sendingAt > now - sendingTimeout) {
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return true;
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const parseUuidFromKey = (key, uniqueName) => {
|
|
778
|
+
const prefix = `${uniqueName}:sendat:`;
|
|
779
|
+
if (!key.startsWith(prefix)) {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
return key.slice(prefix.length);
|
|
783
|
+
};
|
|
784
|
+
|
|
324
785
|
/** Class representing Redis Queue for MailTime */
|
|
325
786
|
class RedisQueue {
|
|
326
787
|
/**
|
|
327
788
|
* Create a RedisQueue instance
|
|
328
|
-
* @param {
|
|
329
|
-
* @param {RedisClient} opts.client - Required, Redis'es `RedisClient` instance, like one returned from `await redis.createClient().connect()` method
|
|
330
|
-
* @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application
|
|
789
|
+
* @param {RedisQueueOption} opts - configuration object
|
|
331
790
|
*/
|
|
332
791
|
constructor (opts) {
|
|
333
792
|
this.name = 'redis-queue';
|
|
334
|
-
if (!opts || typeof opts !== 'object'
|
|
793
|
+
if (!opts || typeof opts !== 'object') {
|
|
335
794
|
throw new TypeError('[mail-time] Configuration object must be passed into RedisQueue constructor');
|
|
336
795
|
}
|
|
337
796
|
|
|
@@ -339,34 +798,41 @@ class RedisQueue {
|
|
|
339
798
|
throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
|
|
340
799
|
}
|
|
341
800
|
|
|
342
|
-
this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : 'default';
|
|
343
|
-
this.uniqueName = `mailtime:${this.prefix}`;
|
|
344
801
|
this.client = opts.client;
|
|
802
|
+
if (typeof opts.prefix === 'string') {
|
|
803
|
+
this.__applyPrefix(opts.prefix);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
345
806
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
807
|
+
/** @internal */
|
|
808
|
+
__applyPrefix(prefix) {
|
|
809
|
+
this.prefix = prefix;
|
|
810
|
+
this.uniqueName = `mailtime:${prefix}`;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** @internal */
|
|
814
|
+
__ensurePrefix() {
|
|
815
|
+
if (typeof this.prefix !== 'string') {
|
|
816
|
+
this.__applyPrefix(this.mailTimeInstance?.prefix || DEFAULT_PREFIX$1);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** @internal */
|
|
821
|
+
__debug(...args) {
|
|
822
|
+
debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* @async
|
|
827
|
+
* @memberOf RedisQueue
|
|
828
|
+
* @name ready
|
|
829
|
+
* @description Storage adapter has no async setup
|
|
830
|
+
* @returns {Promise<void 0>}
|
|
831
|
+
*/
|
|
832
|
+
async ready() {
|
|
833
|
+
this.__ensurePrefix();
|
|
834
|
+
this.__debug('[ready]');
|
|
835
|
+
return void 0;
|
|
370
836
|
}
|
|
371
837
|
|
|
372
838
|
/**
|
|
@@ -377,6 +843,7 @@ class RedisQueue {
|
|
|
377
843
|
* @returns {Promise<object>}
|
|
378
844
|
*/
|
|
379
845
|
async ping() {
|
|
846
|
+
this.__debug('[ping]');
|
|
380
847
|
if (!this.mailTimeInstance) {
|
|
381
848
|
return {
|
|
382
849
|
status: 'Service Unavailable',
|
|
@@ -400,7 +867,7 @@ class RedisQueue {
|
|
|
400
867
|
status: 'Internal Server Error',
|
|
401
868
|
code: 500,
|
|
402
869
|
statusCode: 500,
|
|
403
|
-
error: pingError
|
|
870
|
+
error: pingError,
|
|
404
871
|
};
|
|
405
872
|
}
|
|
406
873
|
|
|
@@ -408,28 +875,64 @@ class RedisQueue {
|
|
|
408
875
|
status: 'Service Unavailable',
|
|
409
876
|
code: 503,
|
|
410
877
|
statusCode: 503,
|
|
411
|
-
error: new Error('Service Unavailable')
|
|
878
|
+
error: new Error('Service Unavailable'),
|
|
412
879
|
};
|
|
413
880
|
}
|
|
414
881
|
|
|
415
882
|
/**
|
|
416
883
|
* @memberOf RedisQueue
|
|
417
884
|
* @name iterate
|
|
418
|
-
* @description iterate over queued tasks passing to `mailTimeInstance.
|
|
419
|
-
* @
|
|
885
|
+
* @description iterate over queued tasks passing each to `mailTimeInstance.___dispatch` (the bounded send pool)
|
|
886
|
+
* @param {{ limit?: number, sendingTimeout?: number }} [opts] - iteration options
|
|
887
|
+
* @returns {Promise<void>}
|
|
420
888
|
*/
|
|
421
|
-
async iterate() {
|
|
889
|
+
async iterate(opts) {
|
|
890
|
+
this.__debug('[iterate]', opts);
|
|
422
891
|
try {
|
|
423
892
|
const now = Date.now();
|
|
893
|
+
const sendingTimeout = (opts && typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0)
|
|
894
|
+
? opts.sendingTimeout
|
|
895
|
+
: 300000;
|
|
896
|
+
const limit = (opts && typeof opts.limit === 'number' && Number.isFinite(opts.limit) && opts.limit > 0)
|
|
897
|
+
? Math.floor(opts.limit)
|
|
898
|
+
: 0;
|
|
899
|
+
const maxTries = (this.mailTimeInstance && typeof this.mailTimeInstance.maxTries === 'number')
|
|
900
|
+
? this.mailTimeInstance.maxTries
|
|
901
|
+
: 60;
|
|
902
|
+
let dispatched = 0;
|
|
903
|
+
|
|
904
|
+
const matchPattern = this.__getKey('*', 'sendat');
|
|
424
905
|
const cursor = this.client.scanIterator({
|
|
425
906
|
TYPE: 'string',
|
|
426
|
-
MATCH:
|
|
907
|
+
MATCH: matchPattern,
|
|
427
908
|
COUNT: 9999,
|
|
428
909
|
});
|
|
429
910
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
911
|
+
outer:
|
|
912
|
+
for await (const cursorValue of cursor) {
|
|
913
|
+
const sendatKeys = Array.isArray(cursorValue) ? cursorValue : [cursorValue];
|
|
914
|
+
for (const sendatKey of sendatKeys) {
|
|
915
|
+
const raw = await this.client.get(sendatKey);
|
|
916
|
+
if (raw === null || parseInt(raw, 10) > now) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const uuid = parseUuidFromKey(sendatKey, this.uniqueName);
|
|
920
|
+
if (!uuid) {
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
const taskJSON = await this.client.get(this.__getKey(uuid));
|
|
924
|
+
if (!taskJSON) {
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
const candidate = JSON.parse(taskJSON);
|
|
928
|
+
if (!isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
await this.mailTimeInstance.___dispatch(candidate);
|
|
932
|
+
dispatched++;
|
|
933
|
+
if (limit > 0 && dispatched >= limit) {
|
|
934
|
+
break outer;
|
|
935
|
+
}
|
|
433
936
|
}
|
|
434
937
|
}
|
|
435
938
|
} catch (iterateError) {
|
|
@@ -447,25 +950,25 @@ class RedisQueue {
|
|
|
447
950
|
* @returns {Promise<object|null>}
|
|
448
951
|
*/
|
|
449
952
|
async getPendingTo(to, sendAt) {
|
|
953
|
+
this.__debug('[getPendingTo]', to, sendAt);
|
|
450
954
|
if (typeof to !== 'string' || typeof sendAt !== 'number') {
|
|
451
955
|
return null;
|
|
452
956
|
}
|
|
453
957
|
|
|
454
958
|
const concatKey = this.__getKey(to, 'concatletter');
|
|
455
|
-
|
|
456
|
-
if (!
|
|
959
|
+
const uuid = await this.client.get(concatKey);
|
|
960
|
+
if (!uuid) {
|
|
457
961
|
return null;
|
|
458
962
|
}
|
|
459
963
|
|
|
460
|
-
const uuid = await this.client.get(concatKey);
|
|
461
964
|
const letterKey = this.__getKey(uuid, 'letter');
|
|
462
|
-
|
|
463
|
-
if (!
|
|
965
|
+
const taskJSON = await this.client.get(letterKey);
|
|
966
|
+
if (!taskJSON) {
|
|
464
967
|
return null;
|
|
465
968
|
}
|
|
466
969
|
|
|
467
|
-
const task = JSON.parse(
|
|
468
|
-
if (!task || task.isSent === true || task.isCancelled === true || task.isFailed === true) {
|
|
970
|
+
const task = JSON.parse(taskJSON);
|
|
971
|
+
if (!task || task.isSent === true || task.isCancelled === true || task.isFailed === true || task.sendAt > sendAt || task.isSending === true || task.tries >= this.mailTimeInstance.maxTries) {
|
|
469
972
|
return null;
|
|
470
973
|
}
|
|
471
974
|
|
|
@@ -481,7 +984,8 @@ class RedisQueue {
|
|
|
481
984
|
* @returns {Promise<void 0>}
|
|
482
985
|
*/
|
|
483
986
|
async push(task) {
|
|
484
|
-
|
|
987
|
+
this.__debug('[push]', task?.uuid);
|
|
988
|
+
if (!task || typeof task !== 'object') {
|
|
485
989
|
return;
|
|
486
990
|
}
|
|
487
991
|
|
|
@@ -489,11 +993,28 @@ class RedisQueue {
|
|
|
489
993
|
task.sendAt = +task.sendAt;
|
|
490
994
|
}
|
|
491
995
|
|
|
492
|
-
|
|
493
|
-
|
|
996
|
+
const letterKey = this.__getKey(task.uuid, 'letter');
|
|
997
|
+
const sendatKey = this.__getKey(task.uuid, 'sendat');
|
|
998
|
+
const taskJSON = JSON.stringify(task);
|
|
999
|
+
|
|
1000
|
+
if (typeof this.client.multi === 'function') {
|
|
1001
|
+
const multi = this.client.multi();
|
|
1002
|
+
multi.set(letterKey, taskJSON);
|
|
1003
|
+
multi.set(sendatKey, `${task.sendAt}`);
|
|
1004
|
+
if (task.to) {
|
|
1005
|
+
multi.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
|
|
1006
|
+
PXAT: task.sendAt - 128,
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
await multi.exec();
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
await this.client.set(letterKey, taskJSON);
|
|
1014
|
+
await this.client.set(sendatKey, `${task.sendAt}`);
|
|
494
1015
|
if (task.to) {
|
|
495
1016
|
await this.client.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
|
|
496
|
-
PXAT: task.sendAt - 128
|
|
1017
|
+
PXAT: task.sendAt - 128,
|
|
497
1018
|
});
|
|
498
1019
|
}
|
|
499
1020
|
}
|
|
@@ -507,18 +1028,19 @@ class RedisQueue {
|
|
|
507
1028
|
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
|
|
508
1029
|
*/
|
|
509
1030
|
async cancel(uuid) {
|
|
1031
|
+
this.__debug('[cancel]', uuid);
|
|
510
1032
|
if (typeof uuid !== 'string') {
|
|
511
1033
|
return false;
|
|
512
1034
|
}
|
|
513
1035
|
|
|
514
1036
|
await this.client.del(this.__getKey(uuid, 'sendat'));
|
|
515
1037
|
const letterKey = this.__getKey(uuid, 'letter');
|
|
516
|
-
const
|
|
517
|
-
if (!
|
|
1038
|
+
const taskJSON = await this.client.get(letterKey);
|
|
1039
|
+
if (!taskJSON) {
|
|
518
1040
|
return false;
|
|
519
1041
|
}
|
|
520
1042
|
|
|
521
|
-
const task = JSON.parse(
|
|
1043
|
+
const task = JSON.parse(taskJSON);
|
|
522
1044
|
if (!task || task.isSent === true || task.isCancelled === true) {
|
|
523
1045
|
return false;
|
|
524
1046
|
}
|
|
@@ -538,23 +1060,62 @@ class RedisQueue {
|
|
|
538
1060
|
* @name remove
|
|
539
1061
|
* @description remove task from queue
|
|
540
1062
|
* @param task {object} - task's object
|
|
1063
|
+
* @param {{ leaseTries: number, leaseSendingAt: number }} [opts] - lease guard: only remove if this worker still holds the lease (tries + sendingAt match, row not cancelled/failed)
|
|
541
1064
|
* @returns {Promise<boolean>} returns `true` if removed or `false` if not found
|
|
542
1065
|
*/
|
|
543
|
-
async remove(task) {
|
|
544
|
-
|
|
1066
|
+
async remove(task, opts) {
|
|
1067
|
+
this.__debug('[remove]', task?.uuid);
|
|
1068
|
+
if (!task || typeof task !== 'object' || typeof task.uuid !== 'string') {
|
|
545
1069
|
return false;
|
|
546
1070
|
}
|
|
547
1071
|
|
|
548
1072
|
const letterKey = this.__getKey(task.uuid, 'letter');
|
|
1073
|
+
if (isSendLeaseRemove(opts)) {
|
|
1074
|
+
if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
try {
|
|
1078
|
+
await this.client.watch(letterKey);
|
|
1079
|
+
const taskJSON = await this.client.get(letterKey);
|
|
1080
|
+
if (!taskJSON) {
|
|
1081
|
+
await this.client.unwatch?.();
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
const currentTask = JSON.parse(taskJSON);
|
|
1085
|
+
if (currentTask.tries !== opts.leaseTries
|
|
1086
|
+
|| currentTask.isSending !== true
|
|
1087
|
+
|| (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) !== opts.leaseSendingAt
|
|
1088
|
+
|| currentTask.isCancelled === true
|
|
1089
|
+
|| currentTask.isFailed === true) {
|
|
1090
|
+
await this.client.unwatch?.();
|
|
1091
|
+
return false;
|
|
1092
|
+
}
|
|
1093
|
+
const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
|
|
1094
|
+
if (task.to) {
|
|
1095
|
+
keysToDelete.push(this.__getKey(task.to, 'concatletter'));
|
|
1096
|
+
}
|
|
1097
|
+
const multi = this.client.multi();
|
|
1098
|
+
for (const key of keysToDelete) {
|
|
1099
|
+
multi.del(key);
|
|
1100
|
+
}
|
|
1101
|
+
const result = await multi.exec();
|
|
1102
|
+
return result !== null;
|
|
1103
|
+
} catch (opError) {
|
|
1104
|
+
logError('[remove] [lease] [opError]', opError);
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
549
1109
|
const exists = await this.client.exists(letterKey);
|
|
550
1110
|
if (!exists) {
|
|
551
1111
|
return false;
|
|
552
1112
|
}
|
|
553
1113
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
this.__getKey(task.
|
|
557
|
-
|
|
1114
|
+
const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
|
|
1115
|
+
if (task.to) {
|
|
1116
|
+
keysToDelete.push(this.__getKey(task.to, 'concatletter'));
|
|
1117
|
+
}
|
|
1118
|
+
await this.client.del(keysToDelete);
|
|
558
1119
|
return true;
|
|
559
1120
|
}
|
|
560
1121
|
|
|
@@ -562,31 +1123,115 @@ class RedisQueue {
|
|
|
562
1123
|
* @async
|
|
563
1124
|
* @memberOf RedisQueue
|
|
564
1125
|
* @name update
|
|
565
|
-
* @description
|
|
1126
|
+
* @description update task in queue
|
|
566
1127
|
* @param task {object} - task's object
|
|
567
1128
|
* @param updateObj {object} - fields with new values to update
|
|
568
1129
|
* @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
|
|
569
1130
|
*/
|
|
570
1131
|
async update(task, updateObj) {
|
|
571
|
-
|
|
1132
|
+
this.__debug('[update]', task?.uuid);
|
|
1133
|
+
if (!task || typeof task !== 'object' || typeof task.uuid !== 'string' || !updateObj || typeof updateObj !== 'object') {
|
|
572
1134
|
return false;
|
|
573
1135
|
}
|
|
574
1136
|
|
|
575
1137
|
const letterKey = this.__getKey(task.uuid, 'letter');
|
|
1138
|
+
const sendatKey = this.__getKey(task.uuid, 'sendat');
|
|
1139
|
+
const isClaim = isSendClaimUpdate(updateObj);
|
|
1140
|
+
const isAppend = isAppendMailOptionUpdate(updateObj);
|
|
1141
|
+
const isLeaseRelease = isSendLeaseGuardedUpdate(updateObj);
|
|
1142
|
+
const now = isClaim && typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
|
|
1143
|
+
const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
|
|
576
1144
|
|
|
577
1145
|
try {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
1146
|
+
if (isAppend) {
|
|
1147
|
+
if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
|
|
1148
|
+
if (!RedisQueue.__atomicAppendWarned) {
|
|
1149
|
+
RedisQueue.__atomicAppendWarned = true;
|
|
1150
|
+
logError('[update] Redis client without watch()/multi() — concat appendMailOption falls back to non-atomic read-modify-write; concurrent folds into the same row may lose letters');
|
|
1151
|
+
}
|
|
1152
|
+
const taskJSON = await this.client.get(letterKey);
|
|
1153
|
+
if (!taskJSON) {
|
|
1154
|
+
return false;
|
|
1155
|
+
}
|
|
1156
|
+
const currentTask = JSON.parse(taskJSON);
|
|
1157
|
+
if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
currentTask.mailOptions = [...(currentTask.mailOptions || []), updateObj.appendMailOption];
|
|
1161
|
+
await this.client.set(letterKey, JSON.stringify(currentTask));
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
await this.client.watch(letterKey);
|
|
1166
|
+
const taskJSON = await this.client.get(letterKey);
|
|
1167
|
+
if (!taskJSON) {
|
|
1168
|
+
await this.client.unwatch?.();
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
const currentTask = JSON.parse(taskJSON);
|
|
1172
|
+
if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
|
|
1173
|
+
await this.client.unwatch?.();
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
currentTask.mailOptions = [...(currentTask.mailOptions || []), updateObj.appendMailOption];
|
|
1177
|
+
const multi = this.client.multi();
|
|
1178
|
+
multi.set(letterKey, JSON.stringify(currentTask));
|
|
1179
|
+
const result = await multi.exec();
|
|
1180
|
+
return result !== null;
|
|
581
1181
|
}
|
|
582
|
-
const updatedTask = { ...task, ...updateObj };
|
|
583
|
-
await this.client.set(letterKey, JSON.stringify(updatedTask));
|
|
584
1182
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1183
|
+
if ((isClaim || isLeaseRelease) && (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function')) {
|
|
1184
|
+
if (isClaim && !RedisQueue.__atomicClaimWarned) {
|
|
1185
|
+
RedisQueue.__atomicClaimWarned = true;
|
|
1186
|
+
logError('[update] Redis client must support watch() and multi() for atomic send claims');
|
|
1187
|
+
}
|
|
1188
|
+
return false;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (isClaim || isLeaseRelease) {
|
|
1192
|
+
await this.client.watch(letterKey);
|
|
1193
|
+
const taskJSON = await this.client.get(letterKey);
|
|
1194
|
+
if (!taskJSON) {
|
|
1195
|
+
await this.client.unwatch?.();
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const currentTask = JSON.parse(taskJSON);
|
|
1200
|
+
if (isClaim && !canClaimTask(currentTask, task, now, sendingTimeout)) {
|
|
1201
|
+
await this.client.unwatch?.();
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1204
|
+
if (isLeaseRelease && !canReleaseLease(currentTask, updateObj)) {
|
|
1205
|
+
await this.client.unwatch?.();
|
|
1206
|
+
return false;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
|
|
1210
|
+
const multi = this.client.multi();
|
|
1211
|
+
multi.set(letterKey, JSON.stringify(updatedTask));
|
|
1212
|
+
if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
|
|
1213
|
+
multi.del(sendatKey);
|
|
1214
|
+
} else if (updatedTask.sendAt) {
|
|
1215
|
+
multi.set(sendatKey, `${+updatedTask.sendAt}`);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const result = await multi.exec();
|
|
1219
|
+
return result !== null;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const taskJSON = await this.client.get(letterKey);
|
|
1223
|
+
if (!taskJSON) {
|
|
1224
|
+
return false;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const currentTask = JSON.parse(taskJSON);
|
|
1228
|
+
const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
|
|
1229
|
+
await this.client.set(letterKey, JSON.stringify(updatedTask));
|
|
1230
|
+
|
|
1231
|
+
if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
|
|
1232
|
+
await this.client.del(sendatKey);
|
|
1233
|
+
} else if (updatedTask.sendAt) {
|
|
1234
|
+
await this.client.set(sendatKey, `${+updatedTask.sendAt}`);
|
|
590
1235
|
}
|
|
591
1236
|
return true;
|
|
592
1237
|
} catch (opError) {
|
|
@@ -595,171 +1240,959 @@ class RedisQueue {
|
|
|
595
1240
|
}
|
|
596
1241
|
}
|
|
597
1242
|
|
|
598
|
-
|
|
599
1243
|
/**
|
|
1244
|
+
* @internal
|
|
600
1245
|
* @memberOf RedisQueue
|
|
601
1246
|
* @name __getKey
|
|
602
1247
|
* @description helper to generate scoped key
|
|
603
|
-
* @param uuid {string} - letter's uuid
|
|
1248
|
+
* @param uuid {string} - letter's uuid (or "to" address for `concatletter` keys)
|
|
604
1249
|
* @param type {string} - "letter" or "sendat" or "concatletter"
|
|
605
1250
|
* @returns {string} returns key used by Redis
|
|
606
1251
|
*/
|
|
607
1252
|
__getKey(uuid, type = 'letter') {
|
|
608
|
-
if (!
|
|
1253
|
+
if (!KEY_TYPES.has(type)) {
|
|
609
1254
|
throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
|
|
610
1255
|
}
|
|
1256
|
+
this.__ensurePrefix();
|
|
611
1257
|
return `${this.uniqueName}:${type}:${uuid}`;
|
|
612
1258
|
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* @typedef {object} PostgresQueryResult
|
|
1263
|
+
* @property {number | null | undefined} [rowCount]
|
|
1264
|
+
* @property {unknown[]} [rows]
|
|
1265
|
+
*/
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* @typedef {object} PostgresClient
|
|
1269
|
+
* @property {(queryText: string, values?: unknown[]) => Promise<PostgresQueryResult>} query
|
|
1270
|
+
*/
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* @typedef {object} PostgresQueueOption
|
|
1274
|
+
* @property {PostgresClient} client
|
|
1275
|
+
* @property {string} [prefix]
|
|
1276
|
+
*/
|
|
613
1277
|
|
|
1278
|
+
const DEFAULT_PREFIX = 'default';
|
|
1279
|
+
|
|
1280
|
+
// Two-key advisory lock, mirroring josk's PostgresAdapter: a stable MailTime namespace
|
|
1281
|
+
// plus a per-prefix hash. `pg_advisory_lock(int4, int4)` lives in its own keyspace, isolated
|
|
1282
|
+
// from single-int callers in the same database; distinct prefixes get distinct lock IDs, so
|
|
1283
|
+
// co-tenant MailTime queues no longer serialize each other's schema setup.
|
|
1284
|
+
const ADVISORY_LOCK_NAMESPACE = 0x4D61696C; // 'Mail' in ASCII as int32
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* @internal
|
|
1288
|
+
* @param {string} prefix
|
|
1289
|
+
* @returns {number} signed int32 hash of the prefix string
|
|
1290
|
+
*/
|
|
1291
|
+
const advisoryLockKeyFor = (prefix) => {
|
|
1292
|
+
return crypto.createHash('sha256').update(prefix).digest().readInt32BE(0);
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
const fieldMap = {
|
|
1296
|
+
to: 'to_address',
|
|
1297
|
+
tries: 'tries',
|
|
1298
|
+
sendAt: 'send_at',
|
|
1299
|
+
isSent: 'is_sent',
|
|
1300
|
+
isCancelled: 'is_cancelled',
|
|
1301
|
+
isFailed: 'is_failed',
|
|
1302
|
+
isSending: 'is_sending',
|
|
1303
|
+
sendingAt: 'sending_at',
|
|
1304
|
+
template: 'template',
|
|
1305
|
+
transport: 'transport',
|
|
1306
|
+
concatSubject: 'concat_subject',
|
|
1307
|
+
mailOptions: 'mail_options',
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
const parseMailOptions = (mailOptions) => {
|
|
1311
|
+
if (typeof mailOptions === 'string') {
|
|
1312
|
+
return JSON.parse(mailOptions);
|
|
1313
|
+
}
|
|
1314
|
+
return mailOptions;
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
const normalizeRow = (row) => {
|
|
1318
|
+
if (!row) {
|
|
1319
|
+
return null;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
return {
|
|
1323
|
+
id: row.id,
|
|
1324
|
+
uuid: row.uuid,
|
|
1325
|
+
to: row.to_address,
|
|
1326
|
+
tries: parseInt(row.tries, 10),
|
|
1327
|
+
sendAt: parseInt(row.send_at, 10),
|
|
1328
|
+
isSent: row.is_sent,
|
|
1329
|
+
isCancelled: row.is_cancelled,
|
|
1330
|
+
isFailed: row.is_failed,
|
|
1331
|
+
isSending: row.is_sending === true,
|
|
1332
|
+
sendingAt: row.sending_at !== null && row.sending_at !== undefined ? parseInt(row.sending_at, 10) : 0,
|
|
1333
|
+
template: row.template || false,
|
|
1334
|
+
transport: parseInt(row.transport, 10),
|
|
1335
|
+
concatSubject: row.concat_subject || false,
|
|
1336
|
+
mailOptions: parseMailOptions(row.mail_options),
|
|
1337
|
+
};
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
/** Class representing PostgreSQL Queue for MailTime */
|
|
1341
|
+
class PostgresQueue {
|
|
614
1342
|
/**
|
|
615
|
-
*
|
|
616
|
-
* @
|
|
617
|
-
* @description list of supported key type
|
|
618
|
-
* @returns {string} returns key used by Redis
|
|
1343
|
+
* Create a PostgresQueue instance
|
|
1344
|
+
* @param {PostgresQueueOption} opts - configuration object
|
|
619
1345
|
*/
|
|
620
|
-
|
|
1346
|
+
constructor(opts) {
|
|
1347
|
+
this.name = 'postgres-queue';
|
|
1348
|
+
if (!opts || typeof opts !== 'object') {
|
|
1349
|
+
throw new TypeError('[mail-time] Configuration object must be passed into PostgresQueue constructor');
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (!opts.client || typeof opts.client.query !== 'function') {
|
|
1353
|
+
throw new Error('[mail-time] [PostgresQueue] required {client} option is missing or does not expose a `query` method');
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
this.client = opts.client;
|
|
1357
|
+
if (typeof opts.prefix === 'string' && opts.prefix.length > 0) {
|
|
1358
|
+
this.__applyPrefix(opts.prefix);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** @internal */
|
|
1363
|
+
__applyPrefix(prefix) {
|
|
1364
|
+
this.prefix = prefix;
|
|
1365
|
+
this.__readyPromise = this.__setup();
|
|
1366
|
+
this.__readyPromise.catch(() => void 0);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** @internal */
|
|
1370
|
+
__ensurePrefix() {
|
|
1371
|
+
if (typeof this.prefix !== 'string') {
|
|
1372
|
+
this.__applyPrefix(this.mailTimeInstance?.prefix || DEFAULT_PREFIX);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** @internal */
|
|
1377
|
+
__debug(...args) {
|
|
1378
|
+
debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* @async
|
|
1383
|
+
* @memberOf PostgresQueue
|
|
1384
|
+
* @name ready
|
|
1385
|
+
* @description Wait until PostgreSQL schema is ready
|
|
1386
|
+
* @returns {Promise<void 0>}
|
|
1387
|
+
*/
|
|
1388
|
+
async ready() {
|
|
1389
|
+
this.__ensurePrefix();
|
|
1390
|
+
this.__debug('[ready]');
|
|
1391
|
+
await this.__readyPromise;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** @internal */
|
|
1395
|
+
async __setup() {
|
|
1396
|
+
const advisoryLockKey = advisoryLockKeyFor(this.prefix);
|
|
1397
|
+
await this.client.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
|
|
1398
|
+
|
|
1399
|
+
try {
|
|
1400
|
+
await this.client.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
|
|
1401
|
+
id BIGSERIAL PRIMARY KEY,
|
|
1402
|
+
prefix TEXT NOT NULL DEFAULT 'default',
|
|
1403
|
+
uuid TEXT NOT NULL,
|
|
1404
|
+
to_address TEXT,
|
|
1405
|
+
tries INTEGER NOT NULL DEFAULT 0,
|
|
1406
|
+
send_at BIGINT NOT NULL,
|
|
1407
|
+
is_sent BOOLEAN NOT NULL DEFAULT false,
|
|
1408
|
+
is_cancelled BOOLEAN NOT NULL DEFAULT false,
|
|
1409
|
+
is_failed BOOLEAN NOT NULL DEFAULT false,
|
|
1410
|
+
is_sending BOOLEAN NOT NULL DEFAULT false,
|
|
1411
|
+
sending_at BIGINT NOT NULL DEFAULT 0,
|
|
1412
|
+
template TEXT,
|
|
1413
|
+
transport INTEGER NOT NULL DEFAULT 0,
|
|
1414
|
+
concat_subject TEXT,
|
|
1415
|
+
mail_options JSONB NOT NULL,
|
|
1416
|
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
1417
|
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
1418
|
+
)`);
|
|
1419
|
+
await this.client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
|
|
1420
|
+
ON mail_time_queue (prefix, uuid)`);
|
|
1421
|
+
|
|
1422
|
+
await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_due
|
|
1423
|
+
ON mail_time_queue (prefix, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
|
|
1424
|
+
|
|
1425
|
+
await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_pending_to
|
|
1426
|
+
ON mail_time_queue (prefix, to_address, is_sent, is_failed, is_cancelled, send_at)`);
|
|
1427
|
+
} finally {
|
|
1428
|
+
await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* @async
|
|
1434
|
+
* @memberOf PostgresQueue
|
|
1435
|
+
* @name ping
|
|
1436
|
+
* @description Check connection to Storage
|
|
1437
|
+
* @returns {Promise<object>}
|
|
1438
|
+
*/
|
|
1439
|
+
async ping() {
|
|
1440
|
+
this.__debug('[ping]');
|
|
1441
|
+
if (!this.mailTimeInstance) {
|
|
1442
|
+
return {
|
|
1443
|
+
status: 'Service Unavailable',
|
|
1444
|
+
code: 503,
|
|
1445
|
+
statusCode: 503,
|
|
1446
|
+
error: new Error('MailTime instance not yet assigned to {mailTimeInstance} of Queue Adapter context'),
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
try {
|
|
1451
|
+
await this.ready();
|
|
1452
|
+
const ping = await this.client.query('SELECT 1 as ping');
|
|
1453
|
+
if (ping?.rows?.[0]?.ping === 1) {
|
|
1454
|
+
return {
|
|
1455
|
+
status: 'OK',
|
|
1456
|
+
code: 200,
|
|
1457
|
+
statusCode: 200,
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
} catch (pingError) {
|
|
1461
|
+
return {
|
|
1462
|
+
status: 'Internal Server Error',
|
|
1463
|
+
code: 500,
|
|
1464
|
+
statusCode: 500,
|
|
1465
|
+
error: pingError,
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
return {
|
|
1470
|
+
status: 'Service Unavailable',
|
|
1471
|
+
code: 503,
|
|
1472
|
+
statusCode: 503,
|
|
1473
|
+
error: new Error('Service Unavailable'),
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* @memberOf PostgresQueue
|
|
1479
|
+
* @name iterate
|
|
1480
|
+
* @description iterate over queued tasks passing each to `mailTimeInstance.___dispatch` (the bounded send pool). Postgres reads buffer the full result, so each tick is bounded by `opts.limit` (or 1000 when caller passes `Infinity` / no limit) to keep memory predictable; high-throughput deployments should shard prefixes.
|
|
1481
|
+
* @param {{ limit?: number, sendingTimeout?: number }} [opts] - iteration options
|
|
1482
|
+
* @returns {Promise<void>}
|
|
1483
|
+
*/
|
|
1484
|
+
async iterate(opts) {
|
|
1485
|
+
this.__debug('[iterate]', opts);
|
|
1486
|
+
if (!this.mailTimeInstance) {
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
await this.ready();
|
|
1490
|
+
|
|
1491
|
+
const now = Date.now();
|
|
1492
|
+
const sendingTimeout = (opts && typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0)
|
|
1493
|
+
? opts.sendingTimeout
|
|
1494
|
+
: 300000;
|
|
1495
|
+
const limit = (opts && typeof opts.limit === 'number' && Number.isFinite(opts.limit) && opts.limit > 0)
|
|
1496
|
+
? Math.max(1, Math.floor(opts.limit))
|
|
1497
|
+
: 1000;
|
|
1498
|
+
|
|
1499
|
+
try {
|
|
1500
|
+
const res = await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
|
|
1501
|
+
is_sending, sending_at, template, transport, concat_subject, mail_options
|
|
1502
|
+
FROM mail_time_queue
|
|
1503
|
+
WHERE prefix = $1
|
|
1504
|
+
AND is_sent = false
|
|
1505
|
+
AND is_failed = false
|
|
1506
|
+
AND is_cancelled = false
|
|
1507
|
+
AND send_at <= $2
|
|
1508
|
+
AND tries < $3
|
|
1509
|
+
AND (is_sending = false OR sending_at <= $4)
|
|
1510
|
+
ORDER BY send_at ASC
|
|
1511
|
+
LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
|
|
1512
|
+
|
|
1513
|
+
for (const row of res.rows || []) {
|
|
1514
|
+
await this.mailTimeInstance.___dispatch(normalizeRow(row));
|
|
1515
|
+
}
|
|
1516
|
+
} catch (iterateError) {
|
|
1517
|
+
logError('[PostgresQueue] [iterate] [iterateError]', iterateError);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* @async
|
|
1523
|
+
* @memberOf PostgresQueue
|
|
1524
|
+
* @name getPendingTo
|
|
1525
|
+
* @description get queued task by `to` field (addressee)
|
|
1526
|
+
* @param to {string} - email address
|
|
1527
|
+
* @param sendAt {number} - timestamp
|
|
1528
|
+
* @returns {Promise<object|null>}
|
|
1529
|
+
*/
|
|
1530
|
+
async getPendingTo(to, sendAt) {
|
|
1531
|
+
this.__debug('[getPendingTo]', to, sendAt);
|
|
1532
|
+
if (typeof to !== 'string' || typeof sendAt !== 'number') {
|
|
1533
|
+
return null;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
await this.ready();
|
|
1537
|
+
|
|
1538
|
+
const res = await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
|
|
1539
|
+
is_sending, sending_at, template, transport, concat_subject, mail_options
|
|
1540
|
+
FROM mail_time_queue
|
|
1541
|
+
WHERE prefix = $1
|
|
1542
|
+
AND to_address = $2
|
|
1543
|
+
AND is_sent = false
|
|
1544
|
+
AND is_failed = false
|
|
1545
|
+
AND is_cancelled = false
|
|
1546
|
+
AND is_sending = false
|
|
1547
|
+
AND tries < $4
|
|
1548
|
+
AND send_at <= $3
|
|
1549
|
+
ORDER BY send_at DESC
|
|
1550
|
+
LIMIT 1`, [this.prefix, to, sendAt, this.mailTimeInstance.maxTries]);
|
|
1551
|
+
|
|
1552
|
+
return normalizeRow(res.rows?.[0]);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* @async
|
|
1557
|
+
* @memberOf PostgresQueue
|
|
1558
|
+
* @name push
|
|
1559
|
+
* @description push task to the queue/storage
|
|
1560
|
+
* @param task {object} - task's object
|
|
1561
|
+
* @returns {Promise<void 0>}
|
|
1562
|
+
*/
|
|
1563
|
+
async push(task) {
|
|
1564
|
+
this.__debug('[push]', task?.uuid);
|
|
1565
|
+
if (!task || typeof task !== 'object') {
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
await this.ready();
|
|
1570
|
+
|
|
1571
|
+
if (task.sendAt instanceof Date) {
|
|
1572
|
+
task.sendAt = +task.sendAt;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
await this.client.query(`INSERT INTO mail_time_queue (
|
|
1576
|
+
prefix, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
|
|
1577
|
+
is_sending, sending_at, template, transport, concat_subject, mail_options, created_at, updated_at
|
|
1578
|
+
)
|
|
1579
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
1580
|
+
ON CONFLICT (prefix, uuid) DO UPDATE SET
|
|
1581
|
+
to_address = EXCLUDED.to_address,
|
|
1582
|
+
tries = EXCLUDED.tries,
|
|
1583
|
+
send_at = EXCLUDED.send_at,
|
|
1584
|
+
is_sent = EXCLUDED.is_sent,
|
|
1585
|
+
is_cancelled = EXCLUDED.is_cancelled,
|
|
1586
|
+
is_failed = EXCLUDED.is_failed,
|
|
1587
|
+
is_sending = EXCLUDED.is_sending,
|
|
1588
|
+
sending_at = EXCLUDED.sending_at,
|
|
1589
|
+
template = EXCLUDED.template,
|
|
1590
|
+
transport = EXCLUDED.transport,
|
|
1591
|
+
concat_subject = EXCLUDED.concat_subject,
|
|
1592
|
+
mail_options = EXCLUDED.mail_options,
|
|
1593
|
+
updated_at = CURRENT_TIMESTAMP`, [
|
|
1594
|
+
this.prefix,
|
|
1595
|
+
task.uuid,
|
|
1596
|
+
typeof task.to === 'string' ? task.to : null,
|
|
1597
|
+
task.tries,
|
|
1598
|
+
task.sendAt,
|
|
1599
|
+
task.isSent,
|
|
1600
|
+
task.isCancelled,
|
|
1601
|
+
task.isFailed,
|
|
1602
|
+
task.isSending === true,
|
|
1603
|
+
typeof task.sendingAt === 'number' ? task.sendingAt : 0,
|
|
1604
|
+
task.template || null,
|
|
1605
|
+
task.transport,
|
|
1606
|
+
task.concatSubject || null,
|
|
1607
|
+
JSON.stringify(task.mailOptions || []),
|
|
1608
|
+
]);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* @async
|
|
1613
|
+
* @memberOf PostgresQueue
|
|
1614
|
+
* @name cancel
|
|
1615
|
+
* @description cancel scheduled email
|
|
1616
|
+
* @param uuid {string} - email's uuid
|
|
1617
|
+
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
|
|
1618
|
+
*/
|
|
1619
|
+
async cancel(uuid) {
|
|
1620
|
+
this.__debug('[cancel]', uuid);
|
|
1621
|
+
if (typeof uuid !== 'string') {
|
|
1622
|
+
return false;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
await this.ready();
|
|
1626
|
+
|
|
1627
|
+
const task = normalizeRow((await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
|
|
1628
|
+
template, transport, concat_subject, mail_options
|
|
1629
|
+
FROM mail_time_queue
|
|
1630
|
+
WHERE prefix = $1
|
|
1631
|
+
AND uuid = $2
|
|
1632
|
+
LIMIT 1`, [this.prefix, uuid])).rows?.[0]);
|
|
1633
|
+
|
|
1634
|
+
if (!task || task.isSent === true || task.isCancelled === true) {
|
|
1635
|
+
return false;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
if (!this.mailTimeInstance.keepHistory) {
|
|
1639
|
+
return await this.remove(task);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
return await this.update(task, {
|
|
1643
|
+
isCancelled: true,
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/**
|
|
1648
|
+
* @async
|
|
1649
|
+
* @memberOf PostgresQueue
|
|
1650
|
+
* @name remove
|
|
1651
|
+
* @description remove task from queue
|
|
1652
|
+
* @param task {object} - task's object
|
|
1653
|
+
* @param {{ leaseTries: number, leaseSendingAt: number }} [opts] - lease guard: only remove if this worker still holds the lease (tries + sendingAt match, row not cancelled/failed)
|
|
1654
|
+
* @returns {Promise<boolean>} returns `true` if removed or `false` if not found
|
|
1655
|
+
*/
|
|
1656
|
+
async remove(task, opts) {
|
|
1657
|
+
this.__debug('[remove]', task?.uuid);
|
|
1658
|
+
if (!task || typeof task !== 'object') {
|
|
1659
|
+
return false;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
await this.ready();
|
|
1663
|
+
|
|
1664
|
+
const where = task.id ? 'id = $2' : 'uuid = $2';
|
|
1665
|
+
const value = task.id || task.uuid;
|
|
1666
|
+
let leaseWhere = '';
|
|
1667
|
+
const params = [this.prefix, value];
|
|
1668
|
+
if (isSendLeaseRemove(opts)) {
|
|
1669
|
+
params.push(opts.leaseTries, opts.leaseSendingAt);
|
|
1670
|
+
leaseWhere = ` AND tries = $3 AND is_sending = true AND sending_at = $4 AND is_cancelled = false AND is_failed = false`;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const res = await this.client.query(`DELETE FROM mail_time_queue
|
|
1674
|
+
WHERE prefix = $1
|
|
1675
|
+
AND ${where}${leaseWhere}`, params);
|
|
1676
|
+
return (res.rowCount || 0) >= 1;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* @async
|
|
1681
|
+
* @memberOf PostgresQueue
|
|
1682
|
+
* @name update
|
|
1683
|
+
* @description update task in queue
|
|
1684
|
+
* @param task {object} - task's object
|
|
1685
|
+
* @param updateObj {object} - fields with new values to update
|
|
1686
|
+
* @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
|
|
1687
|
+
*/
|
|
1688
|
+
async update(task, updateObj) {
|
|
1689
|
+
this.__debug('[update]', task?.uuid);
|
|
1690
|
+
if (!task || typeof task !== 'object' || !updateObj || typeof updateObj !== 'object') {
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
await this.ready();
|
|
1695
|
+
|
|
1696
|
+
if (isAppendMailOptionUpdate(updateObj)) {
|
|
1697
|
+
const where = task.id ? 'id = $3' : 'uuid = $3';
|
|
1698
|
+
const value = task.id || task.uuid;
|
|
1699
|
+
const res = await this.client.query(`UPDATE mail_time_queue
|
|
1700
|
+
SET mail_options = mail_options || $1::jsonb,
|
|
1701
|
+
updated_at = CURRENT_TIMESTAMP
|
|
1702
|
+
WHERE prefix = $2
|
|
1703
|
+
AND ${where}
|
|
1704
|
+
AND is_sent = false
|
|
1705
|
+
AND is_failed = false
|
|
1706
|
+
AND is_cancelled = false
|
|
1707
|
+
AND is_sending = false`, [
|
|
1708
|
+
JSON.stringify([updateObj.appendMailOption]),
|
|
1709
|
+
this.prefix,
|
|
1710
|
+
value,
|
|
1711
|
+
]);
|
|
1712
|
+
return (res.rowCount || 0) >= 1;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
const persistObj = stripInternalUpdateMeta(updateObj);
|
|
1716
|
+
const sets = [];
|
|
1717
|
+
const values = [];
|
|
1718
|
+
for (const key of Object.keys(persistObj)) {
|
|
1719
|
+
if (!fieldMap[key]) {
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
let value = persistObj[key];
|
|
1724
|
+
if (key === 'sendAt' && value instanceof Date) {
|
|
1725
|
+
value = +value;
|
|
1726
|
+
}
|
|
1727
|
+
if (key === 'mailOptions') {
|
|
1728
|
+
value = JSON.stringify(value);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
values.push(value);
|
|
1732
|
+
sets.push(`${fieldMap[key]} = $${values.length}`);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if (!sets.length) {
|
|
1736
|
+
return false;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
let claimWhere = '';
|
|
1740
|
+
if (isSendClaimUpdate(updateObj)) {
|
|
1741
|
+
const now = typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
|
|
1742
|
+
const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
|
|
1743
|
+
values.push(task.tries);
|
|
1744
|
+
const triesIndex = values.length;
|
|
1745
|
+
values.push(now - sendingTimeout);
|
|
1746
|
+
const staleIndex = values.length;
|
|
1747
|
+
claimWhere = `
|
|
1748
|
+
AND is_sent = false
|
|
1749
|
+
AND is_failed = false
|
|
1750
|
+
AND is_cancelled = false
|
|
1751
|
+
AND tries = $${triesIndex}
|
|
1752
|
+
AND (is_sending = false OR sending_at <= $${staleIndex})
|
|
1753
|
+
`;
|
|
1754
|
+
} else if (isSendLeaseGuardedUpdate(updateObj)) {
|
|
1755
|
+
values.push(updateObj.leaseTries);
|
|
1756
|
+
const triesIndex = values.length;
|
|
1757
|
+
values.push(updateObj.leaseSendingAt);
|
|
1758
|
+
const sendingAtIndex = values.length;
|
|
1759
|
+
claimWhere = `
|
|
1760
|
+
AND tries = $${triesIndex}
|
|
1761
|
+
AND is_sending = true
|
|
1762
|
+
AND sending_at = $${sendingAtIndex}
|
|
1763
|
+
AND is_cancelled = false
|
|
1764
|
+
AND is_failed = false
|
|
1765
|
+
`;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
values.push(this.prefix);
|
|
1769
|
+
const prefixIndex = values.length;
|
|
1770
|
+
values.push(task.id || task.uuid);
|
|
1771
|
+
const taskIndex = values.length;
|
|
1772
|
+
const where = task.id ? 'id' : 'uuid';
|
|
1773
|
+
|
|
1774
|
+
const res = await this.client.query(`UPDATE mail_time_queue
|
|
1775
|
+
SET ${sets.join(', ')},
|
|
1776
|
+
updated_at = CURRENT_TIMESTAMP
|
|
1777
|
+
WHERE prefix = $${prefixIndex}
|
|
1778
|
+
AND ${where} = $${taskIndex}
|
|
1779
|
+
${claimWhere}`, values);
|
|
1780
|
+
|
|
1781
|
+
return (res.rowCount || 0) >= 1;
|
|
1782
|
+
}
|
|
621
1783
|
}
|
|
622
1784
|
|
|
623
|
-
|
|
1785
|
+
/**
|
|
1786
|
+
* Default `onError` hook used by every built-in preset. Logs via the
|
|
1787
|
+
* shared `logError` helper and tags the line with the MailTime instance
|
|
1788
|
+
* `prefix` (or `'default'` when unset) so multi-queue deployments can
|
|
1789
|
+
* tell their streams apart. Defined as a regular function so `this`
|
|
1790
|
+
* resolves to the MailTime instance at call time — `this.onError(...)`
|
|
1791
|
+
* in `index.js` binds the receiver. Users override by passing their own
|
|
1792
|
+
* `onError` through `mailTimePreset(name, { onError })` or the
|
|
1793
|
+
* constructor.
|
|
1794
|
+
*/
|
|
1795
|
+
function defaultPresetOnError(error, email, info) {
|
|
1796
|
+
logError(`[${this?.prefix || 'default'}] [onError]`, { error, email, info });
|
|
1797
|
+
}
|
|
624
1798
|
|
|
625
1799
|
/**
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
* @
|
|
629
|
-
* @
|
|
630
|
-
* @
|
|
631
|
-
* @
|
|
1800
|
+
* @typedef {object} MailTimePresetConfig
|
|
1801
|
+
* @property {boolean} [concatEmails]
|
|
1802
|
+
* @property {number} [concatDelay]
|
|
1803
|
+
* @property {string} [concatSubject]
|
|
1804
|
+
* @property {number} [retries]
|
|
1805
|
+
* @property {number} [retryDelay]
|
|
1806
|
+
* @property {number} [revolvingInterval]
|
|
1807
|
+
* @property {number} [sendingTimeout]
|
|
1808
|
+
* @property {'one' | 'batch'} [mode]
|
|
1809
|
+
* @property {number} [concurrency]
|
|
1810
|
+
* @property {(error: unknown, email: any, details?: object) => void} [onError]
|
|
1811
|
+
* @property {object} [josk]
|
|
632
1812
|
*/
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Built-in MailTime presets keyed by use-case. Each value is a partial
|
|
1816
|
+
* MailTime constructor options object — pass it through `mailTimePreset`
|
|
1817
|
+
* (or spread directly) and supply your own `queue` / `transports` /
|
|
1818
|
+
* `josk.adapter` / `prefix`. Presets only set the knobs that differ from
|
|
1819
|
+
* MailTime defaults so the rest of the constructor stays in your hands.
|
|
1820
|
+
*
|
|
1821
|
+
* | Preset | Shape | Best for |
|
|
1822
|
+
* |-----------------|-------|----------|
|
|
1823
|
+
* | `transactional` | High retries, single SMTP per instance, no concat | Receipts, password resets, account changes, welcome emails. |
|
|
1824
|
+
* | `otp` | Few retries, fast retryDelay, snappy polling, parallel SMTPs | Sign-in codes, 2FA, verification codes — stale OTPs aren't worth resending forever. |
|
|
1825
|
+
* | `newsletter` | `concatEmails: true` with 5-min fold window | Scheduled digests / weekly updates / "what's new" emails. |
|
|
1826
|
+
* | `marketing` | High concurrency, moderate retries, no concat | Promotional / campaign blasts where each letter is unique. |
|
|
1827
|
+
* | `notifications` | `concatEmails: true` with 60-s fold window | App / social activity (likes, mentions) where bursts collapse into one letter. |
|
|
1828
|
+
* | `alerts` | Many retries, fast retryDelay, modest concurrency | Ops / admin alerts: monitoring, error reports, escalations. |
|
|
1829
|
+
*
|
|
1830
|
+
* @type {Readonly<Record<'transactional' | 'otp' | 'newsletter' | 'marketing' | 'notifications' | 'alerts', Readonly<MailTimePresetConfig>>>}
|
|
1831
|
+
*/
|
|
1832
|
+
// Every preset pins `mode: 'batch'` explicitly. `'one'` would trade per-tick
|
|
1833
|
+
// throughput for cluster-wide fairness across pods on the same `prefix`, but:
|
|
1834
|
+
// - urgent classes (`otp`, `alerts`, `transactional`) want all due rows claimed
|
|
1835
|
+
// immediately, not spread one-per-tick;
|
|
1836
|
+
// - bulk classes (`newsletter`, `marketing`, `notifications`) are bursty and
|
|
1837
|
+
// need fast drains during the send window;
|
|
1838
|
+
// - multiple `server` pods on the same `prefix` is already an anti-pattern in
|
|
1839
|
+
// this library (one JoSk lease per prefix), so the fairness payoff is moot.
|
|
1840
|
+
// If you do want `'one'`, pass it via `mailTimePreset(name, { mode: 'one' })`.
|
|
1841
|
+
const PRESETS = Object.freeze({
|
|
1842
|
+
transactional: Object.freeze({
|
|
1843
|
+
concatEmails: false,
|
|
1844
|
+
retries: 30,
|
|
1845
|
+
retryDelay: 10_000,
|
|
1846
|
+
mode: 'batch',
|
|
1847
|
+
concurrency: 1,
|
|
1848
|
+
onError: defaultPresetOnError,
|
|
1849
|
+
josk: Object.freeze({
|
|
1850
|
+
zombieTime: 120_000,
|
|
1851
|
+
}),
|
|
1852
|
+
}),
|
|
1853
|
+
otp: Object.freeze({
|
|
1854
|
+
concatEmails: false,
|
|
1855
|
+
retries: 5,
|
|
1856
|
+
retryDelay: 2000,
|
|
1857
|
+
revolvingInterval: 1024,
|
|
1858
|
+
sendingTimeout: 60_000,
|
|
1859
|
+
mode: 'batch',
|
|
1860
|
+
concurrency: 4,
|
|
1861
|
+
onError: defaultPresetOnError,
|
|
1862
|
+
josk: Object.freeze({
|
|
1863
|
+
minRevolvingDelay: 256,
|
|
1864
|
+
maxRevolvingDelay: 1024,
|
|
1865
|
+
zombieTime: 60_000,
|
|
1866
|
+
}),
|
|
1867
|
+
}),
|
|
1868
|
+
newsletter: Object.freeze({
|
|
1869
|
+
concatEmails: true,
|
|
1870
|
+
concatDelay: 5 * 60_000,
|
|
1871
|
+
concatSubject: 'Your updates',
|
|
1872
|
+
retries: 5,
|
|
1873
|
+
retryDelay: 60_000,
|
|
1874
|
+
sendingTimeout: 600_000,
|
|
1875
|
+
mode: 'batch',
|
|
1876
|
+
concurrency: 2,
|
|
1877
|
+
onError: defaultPresetOnError,
|
|
1878
|
+
josk: Object.freeze({
|
|
1879
|
+
zombieTime: 300_000,
|
|
1880
|
+
}),
|
|
1881
|
+
}),
|
|
1882
|
+
marketing: Object.freeze({
|
|
1883
|
+
concatEmails: false,
|
|
1884
|
+
retries: 10,
|
|
1885
|
+
retryDelay: 30_000,
|
|
1886
|
+
mode: 'batch',
|
|
1887
|
+
concurrency: 5,
|
|
1888
|
+
onError: defaultPresetOnError,
|
|
1889
|
+
josk: Object.freeze({
|
|
1890
|
+
zombieTime: 180_000,
|
|
1891
|
+
}),
|
|
1892
|
+
}),
|
|
1893
|
+
notifications: Object.freeze({
|
|
1894
|
+
concatEmails: true,
|
|
1895
|
+
concatDelay: 60_000,
|
|
1896
|
+
concatSubject: 'New activity',
|
|
1897
|
+
retries: 8,
|
|
1898
|
+
retryDelay: 30_000,
|
|
1899
|
+
mode: 'batch',
|
|
1900
|
+
concurrency: 3,
|
|
1901
|
+
onError: defaultPresetOnError,
|
|
1902
|
+
josk: Object.freeze({
|
|
1903
|
+
zombieTime: 180_000,
|
|
1904
|
+
}),
|
|
1905
|
+
}),
|
|
1906
|
+
alerts: Object.freeze({
|
|
1907
|
+
concatEmails: false,
|
|
1908
|
+
retries: 20,
|
|
1909
|
+
retryDelay: 5000,
|
|
1910
|
+
revolvingInterval: 1024,
|
|
1911
|
+
sendingTimeout: 60_000,
|
|
1912
|
+
mode: 'batch',
|
|
1913
|
+
concurrency: 2,
|
|
1914
|
+
onError: defaultPresetOnError,
|
|
1915
|
+
josk: Object.freeze({
|
|
1916
|
+
minRevolvingDelay: 256,
|
|
1917
|
+
maxRevolvingDelay: 1024,
|
|
1918
|
+
zombieTime: 60_000,
|
|
1919
|
+
}),
|
|
1920
|
+
}),
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* Read-only map of preset name → partial MailTime config. Use
|
|
1925
|
+
* `mailTimePreset(name, overrides)` to materialize a merged copy; use
|
|
1926
|
+
* this object directly only when you want to introspect or compose
|
|
1927
|
+
* presets manually.
|
|
1928
|
+
*/
|
|
1929
|
+
const presets = PRESETS;
|
|
1930
|
+
|
|
1931
|
+
/**
|
|
1932
|
+
* @typedef {keyof typeof PRESETS} MailTimePresetName
|
|
1933
|
+
*/
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* Names of every built-in preset.
|
|
1937
|
+
* @type {ReadonlyArray<MailTimePresetName>}
|
|
1938
|
+
*/
|
|
1939
|
+
const presetNames = Object.freeze(/** @type {MailTimePresetName[]} */ (Object.keys(PRESETS)));
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* Materialize a MailTime constructor config from a built-in preset.
|
|
1943
|
+
* The named preset is deep-cloned (so the result is freely mutable) and
|
|
1944
|
+
* `overrides` is deep-merged on top — overrides win for scalar keys, and
|
|
1945
|
+
* nested objects like `josk` are merged so the preset's defaults compose
|
|
1946
|
+
* with the caller's `adapter`, `lockOwnerId`, `onError`, etc.
|
|
1947
|
+
*
|
|
1948
|
+
* ```js
|
|
1949
|
+
* import { MailTime, RedisQueue, mailTimePreset } from 'mail-time';
|
|
1950
|
+
* const mailTime = new MailTime(mailTimePreset('otp', {
|
|
1951
|
+
* prefix: 'otp',
|
|
1952
|
+
* queue: new RedisQueue({ client }),
|
|
1953
|
+
* transports: [otpTransport],
|
|
1954
|
+
* josk: { adapter: { type: 'redis', client } },
|
|
1955
|
+
* }));
|
|
1956
|
+
* ```
|
|
1957
|
+
*
|
|
1958
|
+
* @param {MailTimePresetName} name - one of `presetNames`
|
|
1959
|
+
* @param {object} [overrides] - additional MailTime constructor options
|
|
1960
|
+
* @returns {MailTimePresetConfig} fresh, mutable MailTime constructor options (preset deep-cloned + overrides merged)
|
|
1961
|
+
* @throws {Error} when `name` is unknown
|
|
1962
|
+
* @throws {TypeError} when `overrides` is provided but not a plain object
|
|
1963
|
+
*/
|
|
1964
|
+
const mailTimePreset = (name, overrides) => {
|
|
1965
|
+
if (typeof name !== 'string' || !hasOwnProp(PRESETS, name)) {
|
|
1966
|
+
throw new Error(`[mail-time] [mailTimePreset] unknown preset "${name}". Available: ${presetNames.join(', ')}`);
|
|
1967
|
+
}
|
|
1968
|
+
if (overrides !== void 0 && !isPlainObject(overrides)) {
|
|
1969
|
+
throw new TypeError('[mail-time] [mailTimePreset] {overrides} must be a plain object when provided');
|
|
637
1970
|
}
|
|
1971
|
+
const cloned = deepMerge({}, PRESETS[name]);
|
|
1972
|
+
return overrides ? deepMerge(cloned, overrides) : cloned;
|
|
1973
|
+
};
|
|
638
1974
|
|
|
639
|
-
|
|
640
|
-
|
|
1975
|
+
const noop = () => {};
|
|
1976
|
+
const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
|
|
1977
|
+
|
|
1978
|
+
const __withLease = (task, updateObj) => {
|
|
1979
|
+
if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
|
|
1980
|
+
return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
|
|
641
1981
|
}
|
|
1982
|
+
return updateObj;
|
|
1983
|
+
};
|
|
642
1984
|
|
|
643
|
-
|
|
644
|
-
|
|
1985
|
+
const __leaseRemoveOpts = (task) => {
|
|
1986
|
+
if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
|
|
1987
|
+
return { leaseTries: task.tries, leaseSendingAt: task.sendingAt };
|
|
645
1988
|
}
|
|
1989
|
+
return void 0;
|
|
1990
|
+
};
|
|
646
1991
|
|
|
647
|
-
|
|
648
|
-
|
|
1992
|
+
const createPool = (concurrency) => {
|
|
1993
|
+
const limit = Math.max(1, concurrency | 0);
|
|
1994
|
+
const queue = [];
|
|
1995
|
+
let active = 0;
|
|
1996
|
+
let drainResolvers = [];
|
|
1997
|
+
|
|
1998
|
+
const tryStart = () => {
|
|
1999
|
+
while (active < limit && queue.length > 0) {
|
|
2000
|
+
const job = queue.shift();
|
|
2001
|
+
active++;
|
|
2002
|
+
job.resolveSlot();
|
|
2003
|
+
Promise.resolve()
|
|
2004
|
+
.then(job.fn)
|
|
2005
|
+
.catch((poolError) => {
|
|
2006
|
+
logError('[pool] unhandled send error', poolError);
|
|
2007
|
+
})
|
|
2008
|
+
.finally(() => {
|
|
2009
|
+
active--;
|
|
2010
|
+
if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
|
|
2011
|
+
const resolvers = drainResolvers;
|
|
2012
|
+
drainResolvers = [];
|
|
2013
|
+
for (const r of resolvers) r();
|
|
2014
|
+
}
|
|
2015
|
+
tryStart();
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
};
|
|
2019
|
+
|
|
2020
|
+
return {
|
|
2021
|
+
/**
|
|
2022
|
+
* Queue `fn` for execution under the concurrency limit.
|
|
2023
|
+
* The returned Promise resolves as soon as a slot is acquired and `fn` has started.
|
|
2024
|
+
* It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
|
|
2025
|
+
* @param {() => Promise<void>} fn
|
|
2026
|
+
* @returns {Promise<void>}
|
|
2027
|
+
*/
|
|
2028
|
+
dispatch(fn) {
|
|
2029
|
+
return new Promise((resolveSlot) => {
|
|
2030
|
+
queue.push({ fn, resolveSlot });
|
|
2031
|
+
tryStart();
|
|
2032
|
+
});
|
|
2033
|
+
},
|
|
2034
|
+
drain() {
|
|
2035
|
+
if (active === 0 && queue.length === 0) {
|
|
2036
|
+
return Promise.resolve();
|
|
2037
|
+
}
|
|
2038
|
+
return new Promise((resolve) => drainResolvers.push(resolve));
|
|
2039
|
+
},
|
|
2040
|
+
get size() {
|
|
2041
|
+
return active + queue.length;
|
|
2042
|
+
},
|
|
2043
|
+
};
|
|
2044
|
+
};
|
|
2045
|
+
|
|
2046
|
+
const mailOptionRecipients = (mailOption) => {
|
|
2047
|
+
if (!mailOption) {
|
|
2048
|
+
return [];
|
|
649
2049
|
}
|
|
2050
|
+
return [
|
|
2051
|
+
...toAddressList(mailOption.to),
|
|
2052
|
+
...toAddressList(mailOption.cc),
|
|
2053
|
+
...toAddressList(mailOption.bcc),
|
|
2054
|
+
];
|
|
2055
|
+
};
|
|
650
2056
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
2057
|
+
const collectAcceptedSet = (task) => {
|
|
2058
|
+
const set = new Set();
|
|
2059
|
+
for (const mo of (task?.mailOptions || [])) {
|
|
2060
|
+
if (Array.isArray(mo.accepted)) {
|
|
2061
|
+
for (const addr of mo.accepted) {
|
|
2062
|
+
if (typeof addr === 'string') {
|
|
2063
|
+
set.add(addr.toLowerCase());
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
654
2066
|
}
|
|
2067
|
+
}
|
|
2068
|
+
return set;
|
|
2069
|
+
};
|
|
655
2070
|
|
|
656
|
-
|
|
657
|
-
|
|
2071
|
+
const collectAllRecipients = (task) => {
|
|
2072
|
+
const set = new Set();
|
|
2073
|
+
for (const mo of (task?.mailOptions || [])) {
|
|
2074
|
+
for (const addr of mailOptionRecipients(mo)) {
|
|
2075
|
+
set.add(addr);
|
|
658
2076
|
}
|
|
2077
|
+
}
|
|
2078
|
+
return set;
|
|
2079
|
+
};
|
|
2080
|
+
|
|
2081
|
+
const buildRejectionErrorMap = (info) => {
|
|
2082
|
+
const map = new Map();
|
|
2083
|
+
const rejected = Array.isArray(info?.rejected) ? info.rejected : [];
|
|
2084
|
+
const rejectedErrors = Array.isArray(info?.rejectedErrors) ? info.rejectedErrors : [];
|
|
2085
|
+
for (let i = 0; i < rejected.length; i++) {
|
|
2086
|
+
const addr = extractEmail(rejected[i]);
|
|
2087
|
+
if (!addr) {
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
const err = rejectedErrors[i];
|
|
2091
|
+
map.set(addr, err ? `${err.message || err}` : 'Recipient rejected by transport');
|
|
2092
|
+
}
|
|
2093
|
+
return map;
|
|
2094
|
+
};
|
|
2095
|
+
|
|
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>{{{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
|
+
|
|
2098
|
+
/**
|
|
2099
|
+
* @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
|
|
2100
|
+
*/
|
|
2101
|
+
|
|
2102
|
+
/**
|
|
2103
|
+
* @typedef {import('./presets.js').MailTimePresetConfig} MailTimePresetConfig
|
|
2104
|
+
*/
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* @typedef {{ status: string, code: number, statusCode: number, paused?: boolean, error?: unknown }} MailTimePingResult
|
|
2108
|
+
*/
|
|
2109
|
+
|
|
2110
|
+
/**
|
|
2111
|
+
* @typedef {{ [key: string]: any, query?: (queryText: string, values?: unknown[]) => Promise<{ rows?: unknown[], rowCount?: number | null }> }} MailTimeStorageClient
|
|
2112
|
+
*/
|
|
2113
|
+
|
|
2114
|
+
/**
|
|
2115
|
+
* @typedef {{ [key: string]: any }} MailTimeMongoDb
|
|
2116
|
+
*/
|
|
2117
|
+
|
|
2118
|
+
/**
|
|
2119
|
+
* @typedef {{ [key: string]: any }} MailTimeTransport
|
|
2120
|
+
*/
|
|
2121
|
+
|
|
2122
|
+
/**
|
|
2123
|
+
* @typedef {{ [key: string]: any, type?: 'mongo' | 'redis' | 'postgres', client?: MailTimeStorageClient, db?: MailTimeMongoDb, prefix?: string, resetOnInit?: boolean, useHashTags?: boolean }} MailTimeJoSkAdapterOptions
|
|
2124
|
+
*/
|
|
659
2125
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
for (i = _a.length - 1; i >= 0; i--) {
|
|
664
|
-
let result = false;
|
|
665
|
-
for (j = _b.length - 1; j >= 0; j--) {
|
|
666
|
-
if (equals(_a[i], _b[j])) {
|
|
667
|
-
result = true;
|
|
668
|
-
_a.splice(i, 1);
|
|
669
|
-
_b.splice(j, 1);
|
|
670
|
-
break;
|
|
671
|
-
}
|
|
672
|
-
}
|
|
2126
|
+
/**
|
|
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
|
|
2128
|
+
*/
|
|
673
2129
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
}
|
|
678
|
-
return true;
|
|
679
|
-
}
|
|
2130
|
+
/**
|
|
2131
|
+
* @typedef {{ uuid: string, to?: string | string[], tries: number, sendAt: number, isSent: boolean, isCancelled: boolean, isFailed: boolean, isSending?: boolean, sendingAt?: number, template?: string | false, transport: number, concatSubject?: string | false, mailOptions: MailTimeMailOptions[] }} MailTimeTask
|
|
2132
|
+
*/
|
|
680
2133
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const bkeys = Object.keys(b);
|
|
2134
|
+
/**
|
|
2135
|
+
* @typedef {{ limit?: number, sendingTimeout?: number }} MailTimeIterateOptions
|
|
2136
|
+
*/
|
|
685
2137
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
2138
|
+
/**
|
|
2139
|
+
* @typedef {{ ping: () => Promise<MailTimePingResult>, iterate: (opts?: MailTimeIterateOptions) => Promise<void> | void, getPendingTo: (to: string, sendAt: number) => Promise<MailTimeTask | object | null>, push: (email: MailTimeTask) => Promise<void> | void, cancel: (uuid: string) => Promise<boolean>, remove: (email: MailTimeTask | object, opts?: { leaseTries: number, leaseSendingAt: number }) => Promise<boolean>, update: (email: MailTimeTask | object, updateObj: object) => Promise<boolean>, ready?: () => Promise<void> }} CustomQueue
|
|
2140
|
+
*/
|
|
689
2141
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
}
|
|
2142
|
+
/**
|
|
2143
|
+
* @typedef {{ address: string, error: string }} MailTimeRejectedRecipient
|
|
2144
|
+
*/
|
|
694
2145
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
}
|
|
2146
|
+
/**
|
|
2147
|
+
* @typedef {{ [key: string]: any, to: string | string[], sendAt?: Date | number, template?: string, concatSubject?: string, text?: string | false, html?: string | false, subject?: string, accepted?: string[], rejected?: MailTimeRejectedRecipient[] }} MailTimeMailOptions
|
|
2148
|
+
*/
|
|
699
2149
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
};
|
|
2150
|
+
/**
|
|
2151
|
+
* @typedef {{ subject?: string }} MailTimeConcatEmailsOptions
|
|
2152
|
+
*/
|
|
704
2153
|
|
|
705
|
-
|
|
2154
|
+
/**
|
|
2155
|
+
* @typedef {{ queue: RedisQueue | MongoQueue | PostgresQueue | CustomQueue, type?: 'server' | 'client', from?: string | ((transport: MailTimeTransport) => string), transports?: MailTimeTransport[], strategy?: 'backup' | 'balancer', failsToNext?: number, 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, 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
|
+
*/
|
|
706
2157
|
|
|
707
2158
|
/** Class of MailTime */
|
|
708
2159
|
class MailTime {
|
|
709
2160
|
/**
|
|
710
2161
|
* Create a MailTime instance
|
|
711
|
-
* @param {
|
|
712
|
-
* @param {RedisQueue|MongoQueue|CustomQueue} opts.queue - Queue Storage Driver instance
|
|
713
|
-
* @param {string} [opts.type] - "server" or "client" type of MailTime instance
|
|
714
|
-
* @param {function} [opts.from] - A function returning *String* for `from` header, format: `"MyApp" <user@example.com>`
|
|
715
|
-
* @param {[object]} [opts.transports] - An array of `nodemailer`'s transports, returned from `nodemailer.createTransport({})`; Required for `{type: 'server'}`
|
|
716
|
-
* @param {string} [opts.strategy] - `backup` or `balancer`
|
|
717
|
-
* @param {number} [opts.failsToNext] - After how many failed "send attempts" switch to the next transport? Applied only for `backup` strategy, default - `4`
|
|
718
|
-
* @param {number} [opts.retries] - How many times resend failed emails
|
|
719
|
-
* @param {number} [opts.retryDelay] - Interval in milliseconds between send re-tries
|
|
720
|
-
* @param {boolean} [opts.keepHistory] - Keep queue task as it is in the database
|
|
721
|
-
* @param {boolean} [opts.concatEmails] - Concatenate email by `to` field, default - `false`
|
|
722
|
-
* @param {string} [opts.concatSubject] - Email subject used in concatenated email, default - `Multiple notifications`
|
|
723
|
-
* @param {string} [opts.concatDelimiter] - HTML or plain string delimiter used between concatenated email, default - `<hr>`
|
|
724
|
-
* @param {number} [opts.concatDelay] - HTML or plain string delimiter used between concatenated email, default - `<hr>`
|
|
725
|
-
* @param {number} [opts.revolvingInterval] - Interval in *milliseconds* in between queue checks, default - `256`
|
|
726
|
-
* @param {object|RedisAdapter|MongoAdapter|CustomAdapter} [opts.josk.adapter] - Interval in milliseconds between send re-tries
|
|
727
|
-
* @param {string} [opts.josk.adapter.type] - One of `mongo` or `redis`
|
|
728
|
-
* @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application; By default prefix inherited from MailTime instance
|
|
2162
|
+
* @param {MailTimeOptions} opts - configuration object
|
|
729
2163
|
*/
|
|
730
2164
|
constructor (opts) {
|
|
731
|
-
if (!opts || typeof opts !== 'object'
|
|
2165
|
+
if (!opts || typeof opts !== 'object') {
|
|
732
2166
|
throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor');
|
|
733
2167
|
}
|
|
734
2168
|
|
|
735
2169
|
if (!opts.queue || typeof opts.queue !== 'object') {
|
|
736
|
-
throw new Error('[mail-time] {queue} option is required
|
|
737
|
-
description: 'MailTime requires MongoQueue, RedisQueue, or CustomQueue to connect to an intermediate database'
|
|
738
|
-
});
|
|
2170
|
+
throw new Error('[mail-time] {queue} option is required: provide a MongoQueue, RedisQueue, PostgresQueue, or CustomQueue instance');
|
|
739
2171
|
}
|
|
740
2172
|
|
|
741
2173
|
this.queue = opts.queue;
|
|
742
|
-
this.queue.mailTimeInstance = this;
|
|
743
|
-
const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
|
|
744
2174
|
|
|
745
2175
|
for (let i = queueMethods.length - 1; i >= 0; i--) {
|
|
746
2176
|
if (typeof this.queue[queueMethods[i]] !== 'function') {
|
|
747
|
-
throw new Error(`{queue} instance is missing {${queueMethods[i]}} method that is required!`);
|
|
2177
|
+
throw new Error(`[mail-time] {queue} instance is missing {${queueMethods[i]}} method that is required!`);
|
|
748
2178
|
}
|
|
749
2179
|
}
|
|
750
2180
|
|
|
751
|
-
this.debug =
|
|
752
|
-
this.
|
|
753
|
-
debug(this.debug, ...args);
|
|
2181
|
+
this.debug = opts.debug === true;
|
|
2182
|
+
this.__debug = (...args) => {
|
|
2183
|
+
debug(this.debug, `[${this.prefix || 'default'}]`, ...args);
|
|
754
2184
|
};
|
|
755
2185
|
|
|
756
|
-
this.type = (
|
|
2186
|
+
this.type = (opts.type === 'client' || opts.type === 'server') ? opts.type : 'server';
|
|
757
2187
|
this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '';
|
|
758
2188
|
|
|
759
2189
|
if (typeof opts.retries === 'number') {
|
|
2190
|
+
if (opts.retries < 0) {
|
|
2191
|
+
throw new Error('[mail-time] {retries} must be a non-negative number');
|
|
2192
|
+
}
|
|
760
2193
|
this.maxTries = opts.retries + 1;
|
|
761
2194
|
} else if (typeof opts.maxTries === 'number') {
|
|
762
|
-
this.maxTries = (opts.maxTries < 1) ? 1 :
|
|
2195
|
+
this.maxTries = (opts.maxTries < 1) ? 1 : opts.maxTries;
|
|
763
2196
|
} else {
|
|
764
2197
|
this.maxTries = 60;
|
|
765
2198
|
}
|
|
@@ -773,33 +2206,53 @@ class MailTime {
|
|
|
773
2206
|
}
|
|
774
2207
|
|
|
775
2208
|
this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
|
|
776
|
-
this.keepHistory =
|
|
777
|
-
this.onSent = opts.onSent
|
|
778
|
-
this.onError = opts.onError
|
|
779
|
-
|
|
780
|
-
this.revolvingInterval = opts.revolvingInterval
|
|
781
|
-
|
|
782
|
-
this.
|
|
2209
|
+
this.keepHistory = opts.keepHistory === true;
|
|
2210
|
+
this.onSent = (typeof opts.onSent === 'function') ? opts.onSent.bind(this) : noop;
|
|
2211
|
+
this.onError = (typeof opts.onError === 'function') ? opts.onError.bind(this) : noop;
|
|
2212
|
+
|
|
2213
|
+
this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
|
|
2214
|
+
this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
|
|
2215
|
+
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;
|
|
2217
|
+
this.__isDestroyed = false;
|
|
2218
|
+
this.__abortInFlight = false;
|
|
2219
|
+
this.__isPaused = false;
|
|
2220
|
+
this.__readyPromise = null;
|
|
2221
|
+
this.__schedulerTimer = null;
|
|
2222
|
+
this.__inFlight = new Set();
|
|
2223
|
+
this.__pool = createPool(this.concurrency);
|
|
2224
|
+
|
|
2225
|
+
this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
|
|
783
2226
|
this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup';
|
|
784
|
-
this.transports = opts.transports
|
|
2227
|
+
this.transports = Array.isArray(opts.transports) ? opts.transports : [];
|
|
785
2228
|
this.transport = 0;
|
|
786
|
-
this.
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
2229
|
+
this.verifyTransports = opts.verifyTransports !== false;
|
|
2230
|
+
this.__unhealthyTransports = new Set();
|
|
2231
|
+
|
|
2232
|
+
if (typeof opts.from === 'string') {
|
|
2233
|
+
const fromStr = opts.from;
|
|
2234
|
+
this.from = () => fromStr;
|
|
2235
|
+
} else if (typeof opts.from === 'function') {
|
|
2236
|
+
this.from = opts.from;
|
|
2237
|
+
} else {
|
|
2238
|
+
this.from = false;
|
|
2239
|
+
}
|
|
796
2240
|
|
|
797
|
-
|
|
798
|
-
})();
|
|
2241
|
+
this.queue.mailTimeInstance = this;
|
|
799
2242
|
|
|
800
|
-
|
|
801
|
-
this.concatSubject = (
|
|
802
|
-
|
|
2243
|
+
/** @type {string} */
|
|
2244
|
+
this.concatSubject = (typeof opts.concatSubject === 'string' && opts.concatSubject) ? opts.concatSubject : 'Multiple notifications';
|
|
2245
|
+
if (opts.concatEmails === true) {
|
|
2246
|
+
this.concatEmails = true;
|
|
2247
|
+
} else if (isPlainObject(opts.concatEmails)) {
|
|
2248
|
+
this.concatEmails = true;
|
|
2249
|
+
if (typeof opts.concatEmails.subject === 'string' && opts.concatEmails.subject) {
|
|
2250
|
+
this.concatSubject = opts.concatEmails.subject;
|
|
2251
|
+
}
|
|
2252
|
+
} else {
|
|
2253
|
+
this.concatEmails = false;
|
|
2254
|
+
}
|
|
2255
|
+
this.concatDelimiter = (typeof opts.concatDelimiter === 'string' && opts.concatDelimiter) ? opts.concatDelimiter : '<hr>';
|
|
803
2256
|
|
|
804
2257
|
if (typeof opts.concatDelay === 'number') {
|
|
805
2258
|
this.concatDelay = opts.concatDelay;
|
|
@@ -809,63 +2262,90 @@ class MailTime {
|
|
|
809
2262
|
this.concatDelay = 60000;
|
|
810
2263
|
}
|
|
811
2264
|
|
|
812
|
-
this.
|
|
813
|
-
this.
|
|
814
|
-
this.
|
|
815
|
-
this.
|
|
816
|
-
this.
|
|
817
|
-
this.
|
|
818
|
-
this.
|
|
819
|
-
this.
|
|
820
|
-
this.
|
|
2265
|
+
this.__debug('DEBUG ON {debug: true}');
|
|
2266
|
+
this.__debug(`INITIALIZING [type: ${this.type}]`);
|
|
2267
|
+
this.__debug(`INITIALIZING [strategy: ${this.strategy}]`);
|
|
2268
|
+
this.__debug(`INITIALIZING [josk.adapter.type: ${opts?.josk?.adapter?.type || 'custom'}]`);
|
|
2269
|
+
this.__debug(`INITIALIZING [prefix: ${this.prefix}]`);
|
|
2270
|
+
this.__debug(`INITIALIZING [retries: ${this.maxTries - 1}]`);
|
|
2271
|
+
this.__debug(`INITIALIZING [failsToNext: ${this.failsToNext}]`);
|
|
2272
|
+
this.__debug(`INITIALIZING [mode: ${this.mode}]`);
|
|
2273
|
+
this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
|
|
2274
|
+
this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
|
|
821
2275
|
|
|
822
2276
|
/** SERVER-SPECIFIC CHECKS AND CONFIG */
|
|
823
2277
|
if (this.type === 'server') {
|
|
824
|
-
if (
|
|
825
|
-
throw new Error('[mail-time] {transports} is required for {type: "server"} and must be
|
|
2278
|
+
if (!this.transports.length) {
|
|
2279
|
+
throw new Error('[mail-time] {transports} is required for {type: "server"} and must be a non-empty Array, like one returned from `nodemailer.createTransport`');
|
|
826
2280
|
}
|
|
827
2281
|
|
|
828
|
-
if (typeof opts.josk !== 'object') {
|
|
2282
|
+
if (!opts.josk || typeof opts.josk !== 'object') {
|
|
829
2283
|
throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}');
|
|
830
2284
|
}
|
|
831
2285
|
|
|
832
|
-
if (typeof opts.josk.adapter !== 'object') {
|
|
2286
|
+
if (!opts.josk.adapter || typeof opts.josk.adapter !== 'object') {
|
|
833
2287
|
throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class');
|
|
834
2288
|
}
|
|
835
2289
|
|
|
836
2290
|
this.josk = { ...opts.josk };
|
|
2291
|
+
const buildAdapterOptions = () => {
|
|
2292
|
+
const adapterOptions = {
|
|
2293
|
+
prefix: `mailTimeQueue${this.prefix}`,
|
|
2294
|
+
...opts.josk.adapter,
|
|
2295
|
+
};
|
|
2296
|
+
|
|
2297
|
+
if (typeof opts.josk.resetOnInit === 'boolean' && typeof adapterOptions.resetOnInit !== 'boolean') {
|
|
2298
|
+
adapterOptions.resetOnInit = opts.josk.resetOnInit;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
return adapterOptions;
|
|
2302
|
+
};
|
|
837
2303
|
|
|
838
|
-
|
|
2304
|
+
const adapterType = opts.josk.adapter.type;
|
|
2305
|
+
if (adapterType === 'mongo') {
|
|
839
2306
|
if (!opts.josk.adapter.db) {
|
|
840
2307
|
throw new Error('[mail-time] {josk.adapter.db} option required for {josk.adapter.type: "mongo"}');
|
|
841
2308
|
}
|
|
842
|
-
this.josk.adapter = new josk.MongoAdapter(
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'redis') {
|
|
2309
|
+
this.josk.adapter = new josk.MongoAdapter(buildAdapterOptions());
|
|
2310
|
+
} else if (adapterType === 'redis') {
|
|
846
2311
|
if (!opts.josk.adapter.client) {
|
|
847
2312
|
throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "redis"}');
|
|
848
2313
|
}
|
|
849
|
-
this.josk.adapter = new josk.RedisAdapter(
|
|
2314
|
+
this.josk.adapter = new josk.RedisAdapter(buildAdapterOptions());
|
|
2315
|
+
} else if (adapterType === 'postgres') {
|
|
2316
|
+
if (!opts.josk.adapter.client) {
|
|
2317
|
+
throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "postgres"}');
|
|
2318
|
+
}
|
|
2319
|
+
this.josk.adapter = new josk.PostgresAdapter(buildAdapterOptions());
|
|
850
2320
|
}
|
|
851
2321
|
|
|
852
2322
|
this.josk.minRevolvingDelay = (typeof opts.josk.minRevolvingDelay === 'number') ? opts.josk.minRevolvingDelay : 512;
|
|
853
2323
|
this.josk.maxRevolvingDelay = (typeof opts.josk.maxRevolvingDelay === 'number') ? opts.josk.maxRevolvingDelay : 2048;
|
|
854
|
-
this.josk.zombieTime = (typeof opts.josk.zombieTime === 'number') ? opts.josk.zombieTime :
|
|
2324
|
+
this.josk.zombieTime = (typeof opts.josk.zombieTime === 'number') ? opts.josk.zombieTime : 60000;
|
|
2325
|
+
this.josk.execute = (opts.josk.execute === 'one' || opts.josk.execute === 'batch') ? opts.josk.execute : 'batch';
|
|
2326
|
+
this.josk.concurrency = (typeof opts.josk.concurrency === 'number' && opts.josk.concurrency > 0) ? opts.josk.concurrency : Infinity;
|
|
2327
|
+
this.josk.autoClear = opts.josk.autoClear === true;
|
|
2328
|
+
|
|
2329
|
+
if (typeof opts.josk.lockOwnerId === 'string' && opts.josk.lockOwnerId.length > 0) {
|
|
2330
|
+
this.josk.lockOwnerId = opts.josk.lockOwnerId;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
if (typeof opts.josk.onError !== 'function') {
|
|
2334
|
+
this.josk.onError = (title, details) => {
|
|
2335
|
+
logError(`[scheduler] ${title}`, details);
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
855
2338
|
|
|
856
2339
|
this.scheduler = new josk.JoSk({
|
|
857
2340
|
debug: this.debug,
|
|
858
2341
|
...this.josk,
|
|
859
2342
|
});
|
|
860
2343
|
|
|
861
|
-
|
|
862
|
-
if ((await this.scheduler.ping()).status !== 'OK') {
|
|
863
|
-
throw new Error('[mail-time] [MailTime#ping] can not connect to storage, make sure it is available and properly configured');
|
|
864
|
-
}
|
|
865
|
-
});
|
|
866
|
-
|
|
867
|
-
this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
|
|
2344
|
+
this.__schedulerTimer = this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
|
|
868
2345
|
}
|
|
2346
|
+
|
|
2347
|
+
this.__readyPromise = this.___ready();
|
|
2348
|
+
this.__readyPromise.catch(() => void 0);
|
|
869
2349
|
}
|
|
870
2350
|
|
|
871
2351
|
static get Template() {
|
|
@@ -881,26 +2361,134 @@ class MailTime {
|
|
|
881
2361
|
* @memberOf MailTime
|
|
882
2362
|
* @name ping
|
|
883
2363
|
* @description Check package readiness and connection to Storage
|
|
884
|
-
* @returns {Promise<
|
|
885
|
-
* @throws {
|
|
2364
|
+
* @returns {Promise<MailTimePingResult>}
|
|
2365
|
+
* @throws {Error}
|
|
886
2366
|
*/
|
|
887
2367
|
async ping() {
|
|
888
|
-
this.
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
2368
|
+
this.__debug('[ping]');
|
|
2369
|
+
if (this.scheduler) {
|
|
2370
|
+
const schedulerPing = await this.scheduler.ping();
|
|
2371
|
+
if (schedulerPing.status !== 'OK') {
|
|
2372
|
+
return { ...schedulerPing, paused: this.__isPaused };
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
const queuePing = await this.queue.ping();
|
|
2376
|
+
return { ...queuePing, paused: this.__isPaused };
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
/**
|
|
2380
|
+
* @async
|
|
2381
|
+
* @memberOf MailTime
|
|
2382
|
+
* @name ready
|
|
2383
|
+
* @description Wait until queue and scheduler storage are ready
|
|
2384
|
+
* @returns {Promise<MailTime>}
|
|
2385
|
+
*/
|
|
2386
|
+
async ready() {
|
|
2387
|
+
this.__debug('[ready]');
|
|
2388
|
+
return await this.__readyPromise;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/**
|
|
2392
|
+
* @memberOf MailTime
|
|
2393
|
+
* @name destroy
|
|
2394
|
+
* @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, any in-flight SMTP attempts are neutralized on completion — they perform no storage writes, no `onSent`/`onError` callbacks, and no logging once `destroy()` returns; their claimed rows are recovered by stale-lock timeout (`sendingTimeout`). Pass `{ drain: true }` to instead let in-flight attempts run to completion (returns a Promise that resolves once they settle).
|
|
2395
|
+
* @param {{ drain?: boolean }} [opts]
|
|
2396
|
+
* @returns {boolean | Promise<boolean>}
|
|
2397
|
+
*/
|
|
2398
|
+
destroy(opts) {
|
|
2399
|
+
this.__debug('[destroy]', opts);
|
|
2400
|
+
if (this.__isDestroyed) {
|
|
2401
|
+
return false;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
this.__isDestroyed = true;
|
|
2405
|
+
this.__isPaused = false;
|
|
2406
|
+
if (this.scheduler && typeof this.scheduler.destroy === 'function') {
|
|
2407
|
+
this.scheduler.destroy();
|
|
2408
|
+
}
|
|
2409
|
+
if (opts && opts.drain === true && this.__pool) {
|
|
2410
|
+
return this.__pool.drain().then(() => true);
|
|
2411
|
+
}
|
|
2412
|
+
// No graceful drain requested: neutralize in-flight SMTP completions so a
|
|
2413
|
+
// destroyed instance performs no storage writes, no `onSent`/`onError`
|
|
2414
|
+
// callbacks, and no logging after teardown. Each claimed row keeps its
|
|
2415
|
+
// `isSending` lock, which a peer or a future instance recovers once
|
|
2416
|
+
// `sendingTimeout` elapses — identical to a crash mid-send. Use
|
|
2417
|
+
// `destroy({ drain: true })` (or `await drain()` before `destroy()`) when
|
|
2418
|
+
// in-flight sends must run to completion.
|
|
2419
|
+
this.__abortInFlight = true;
|
|
2420
|
+
return true;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/**
|
|
2424
|
+
* @async
|
|
2425
|
+
* @memberOf MailTime
|
|
2426
|
+
* @name drain
|
|
2427
|
+
* @description Wait for all in-flight email send attempts to settle
|
|
2428
|
+
* @returns {Promise<void>}
|
|
2429
|
+
*/
|
|
2430
|
+
async drain() {
|
|
2431
|
+
this.__debug('[drain]');
|
|
2432
|
+
if (this.__pool) {
|
|
2433
|
+
await this.__pool.drain();
|
|
892
2434
|
}
|
|
893
|
-
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
/**
|
|
2438
|
+
* @memberOf MailTime
|
|
2439
|
+
* @name pause
|
|
2440
|
+
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
2441
|
+
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
2442
|
+
*/
|
|
2443
|
+
pause() {
|
|
2444
|
+
this.__debug('[pause]');
|
|
2445
|
+
if (this.__isDestroyed || !this.scheduler) {
|
|
2446
|
+
return false;
|
|
2447
|
+
}
|
|
2448
|
+
const paused = this.scheduler.pause();
|
|
2449
|
+
if (paused) {
|
|
2450
|
+
this.__isPaused = true;
|
|
2451
|
+
}
|
|
2452
|
+
return paused;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
/**
|
|
2456
|
+
* @memberOf MailTime
|
|
2457
|
+
* @name resume
|
|
2458
|
+
* @description Resume competing for the queue-drain lease after `pause()`; triggers an immediate scan. No-op on `client` instances, after `destroy()`, or when not paused.
|
|
2459
|
+
* @returns {boolean} `true` if newly resumed; `false` if not paused, a client instance, or destroyed
|
|
2460
|
+
*/
|
|
2461
|
+
resume() {
|
|
2462
|
+
this.__debug('[resume]');
|
|
2463
|
+
if (this.__isDestroyed || !this.scheduler) {
|
|
2464
|
+
return false;
|
|
2465
|
+
}
|
|
2466
|
+
const resumed = this.scheduler.resume();
|
|
2467
|
+
if (resumed) {
|
|
2468
|
+
this.__isPaused = false;
|
|
2469
|
+
}
|
|
2470
|
+
return resumed;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
/**
|
|
2474
|
+
* @memberOf MailTime
|
|
2475
|
+
* @name isPaused
|
|
2476
|
+
* @description Whether this instance is currently paused from draining the queue. Always `false` on `client` instances.
|
|
2477
|
+
* @returns {boolean}
|
|
2478
|
+
*/
|
|
2479
|
+
get isPaused() {
|
|
2480
|
+
return this.__isPaused;
|
|
894
2481
|
}
|
|
895
2482
|
|
|
896
2483
|
/**
|
|
897
2484
|
* @memberOf MailTime
|
|
898
2485
|
* @name send
|
|
899
2486
|
* @description alias of `sendMail`
|
|
2487
|
+
* @param {MailTimeMailOptions} opts - email options
|
|
900
2488
|
* @returns {Promise<string>} uuid of the email
|
|
901
2489
|
*/
|
|
902
2490
|
async send(opts) {
|
|
903
|
-
this.
|
|
2491
|
+
this.__debug('[send]', opts);
|
|
904
2492
|
return await this.sendMail(opts);
|
|
905
2493
|
}
|
|
906
2494
|
|
|
@@ -909,49 +2497,34 @@ class MailTime {
|
|
|
909
2497
|
* @memberOf MailTime
|
|
910
2498
|
* @name sendMail
|
|
911
2499
|
* @description add email to the queue or append to existing letter if {concatEmails: true}
|
|
912
|
-
* @param
|
|
913
|
-
* @param opts.sendAt {Date|number} - When email should be sent
|
|
914
|
-
* @param opts.template {string} - Email-specific template
|
|
915
|
-
* @param opts[key] {mix} - Other NodeMailer's options
|
|
2500
|
+
* @param {MailTimeMailOptions} opts - email options
|
|
916
2501
|
* @returns {Promise<string>} uuid of the email
|
|
917
2502
|
* @throws {Error}
|
|
918
2503
|
*/
|
|
919
2504
|
async sendMail(opts = {}) {
|
|
920
|
-
this.
|
|
2505
|
+
this.__debug('[sendMail]', opts);
|
|
921
2506
|
if (!opts.html && !opts.text) {
|
|
922
|
-
throw new Error('`html` nor `text` field is
|
|
2507
|
+
throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
|
|
923
2508
|
}
|
|
924
2509
|
|
|
925
2510
|
let sendAt = opts.sendAt;
|
|
926
|
-
if (!sendAt) {
|
|
927
|
-
sendAt = Date.now();
|
|
928
|
-
}
|
|
929
|
-
|
|
930
2511
|
if (sendAt instanceof Date) {
|
|
931
2512
|
sendAt = +sendAt;
|
|
932
2513
|
}
|
|
933
|
-
|
|
934
|
-
if (typeof sendAt !== 'number') {
|
|
2514
|
+
if (typeof sendAt !== 'number' || !Number.isFinite(sendAt)) {
|
|
935
2515
|
sendAt = Date.now();
|
|
936
2516
|
}
|
|
937
2517
|
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
template = false;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
let concatSubject = opts.concatSubject;
|
|
944
|
-
if (typeof concatSubject !== 'string') {
|
|
945
|
-
concatSubject = false;
|
|
946
|
-
}
|
|
2518
|
+
const template = (typeof opts.template === 'string') ? opts.template : false;
|
|
2519
|
+
const concatSubject = (typeof opts.concatSubject === 'string') ? opts.concatSubject : false;
|
|
947
2520
|
|
|
948
2521
|
const mailOptions = { ...opts };
|
|
949
2522
|
delete mailOptions.sendAt;
|
|
950
2523
|
delete mailOptions.template;
|
|
951
2524
|
delete mailOptions.concatSubject;
|
|
952
2525
|
|
|
953
|
-
if (typeof mailOptions.to !== 'string' && (!(mailOptions.to
|
|
954
|
-
throw new Error('[mail-time] `mailOptions.to` is required and must be a string or non-empty Array');
|
|
2526
|
+
if (typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
|
|
2527
|
+
throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
|
|
955
2528
|
}
|
|
956
2529
|
|
|
957
2530
|
if (this.concatEmails) {
|
|
@@ -967,11 +2540,12 @@ class MailTime {
|
|
|
967
2540
|
}
|
|
968
2541
|
}
|
|
969
2542
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
mailOptions: pendingMailOptions
|
|
2543
|
+
const appended = await this.queue.update(task, {
|
|
2544
|
+
appendMailOption: mailOptions,
|
|
973
2545
|
});
|
|
974
|
-
|
|
2546
|
+
if (appended) {
|
|
2547
|
+
return task.uuid;
|
|
2548
|
+
}
|
|
975
2549
|
}
|
|
976
2550
|
}
|
|
977
2551
|
|
|
@@ -979,18 +2553,20 @@ class MailTime {
|
|
|
979
2553
|
sendAt,
|
|
980
2554
|
template,
|
|
981
2555
|
concatSubject,
|
|
982
|
-
mailOptions
|
|
2556
|
+
mailOptions,
|
|
983
2557
|
});
|
|
984
2558
|
}
|
|
985
2559
|
|
|
986
2560
|
/**
|
|
2561
|
+
* @async
|
|
987
2562
|
* @memberOf MailTime
|
|
988
2563
|
* @name cancel
|
|
989
2564
|
* @description alias of `cancelMail`
|
|
2565
|
+
* @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
|
|
990
2566
|
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
|
|
991
2567
|
*/
|
|
992
2568
|
async cancel(uuid) {
|
|
993
|
-
this.
|
|
2569
|
+
this.__debug('[cancel]', uuid);
|
|
994
2570
|
return await this.cancelMail(uuid);
|
|
995
2571
|
}
|
|
996
2572
|
|
|
@@ -999,47 +2575,66 @@ class MailTime {
|
|
|
999
2575
|
* @memberOf MailTime
|
|
1000
2576
|
* @name cancelMail
|
|
1001
2577
|
* @description remove email from the queue or mark as `isCancelled`
|
|
1002
|
-
* @param
|
|
2578
|
+
* @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
|
|
1003
2579
|
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
|
|
1004
2580
|
*/
|
|
1005
2581
|
async cancelMail(uuid) {
|
|
1006
|
-
this.
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
}
|
|
1010
|
-
return await this.queue.cancel(uuid);
|
|
2582
|
+
this.__debug('[cancelMail]', uuid);
|
|
2583
|
+
const resolved = (uuid && typeof uuid.then === 'function') ? await uuid : uuid;
|
|
2584
|
+
return await this.queue.cancel(resolved);
|
|
1011
2585
|
}
|
|
1012
2586
|
|
|
1013
2587
|
/**
|
|
1014
2588
|
* @async
|
|
2589
|
+
* @internal
|
|
1015
2590
|
* @memberOf MailTime
|
|
1016
2591
|
* @name ___handleError
|
|
1017
2592
|
* @description Handle runtime errors and pass to `onError` callback
|
|
1018
|
-
* @param
|
|
1019
|
-
* @param
|
|
1020
|
-
* @param
|
|
2593
|
+
* @param {MailTimeTask} task - Email task record from Storage
|
|
2594
|
+
* @param {unknown} error - Error String/Object/Error
|
|
2595
|
+
* @param {object} info - Info object returned from nodemailer
|
|
1021
2596
|
* @returns {Promise<void 0>}
|
|
1022
2597
|
*/
|
|
1023
2598
|
async ___handleError(task, error, info) {
|
|
1024
|
-
this.
|
|
2599
|
+
this.__debug('[private handleError]', { task, error, info });
|
|
2600
|
+
if (this.__abortInFlight) {
|
|
2601
|
+
this.__debug('[private handleError] instance destroyed; skipping onError', task?.uuid);
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
1025
2604
|
if (!task) {
|
|
1026
2605
|
return;
|
|
1027
2606
|
}
|
|
1028
2607
|
|
|
1029
2608
|
if (task.tries >= this.maxTries) {
|
|
1030
|
-
task
|
|
1031
|
-
|
|
2609
|
+
this.___finalizeRejected(task, info);
|
|
2610
|
+
|
|
2611
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
2612
|
+
const leaseUpdate = __withLease(task, {
|
|
2613
|
+
isSent: false,
|
|
2614
|
+
isFailed: true,
|
|
2615
|
+
isSending: false,
|
|
2616
|
+
sendingAt: 0,
|
|
2617
|
+
mailOptions: task.mailOptions,
|
|
2618
|
+
});
|
|
1032
2619
|
|
|
2620
|
+
let finalized = false;
|
|
1033
2621
|
if (!this.keepHistory) {
|
|
1034
|
-
await this.queue.remove(task);
|
|
2622
|
+
finalized = await this.queue.remove(task, leaseRemove);
|
|
1035
2623
|
} else {
|
|
1036
|
-
await this.queue.update(task,
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
2624
|
+
finalized = await this.queue.update(task, leaseUpdate);
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
if (!finalized) {
|
|
2628
|
+
this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
|
|
2629
|
+
return;
|
|
1040
2630
|
}
|
|
1041
2631
|
|
|
1042
|
-
|
|
2632
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
|
|
2633
|
+
task.isSent = false;
|
|
2634
|
+
task.isFailed = true;
|
|
2635
|
+
task.isSending = false;
|
|
2636
|
+
|
|
2637
|
+
this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
|
|
1043
2638
|
this.onError(error, task, info);
|
|
1044
2639
|
return;
|
|
1045
2640
|
}
|
|
@@ -1047,43 +2642,50 @@ class MailTime {
|
|
|
1047
2642
|
let transportIndex = task.transport;
|
|
1048
2643
|
|
|
1049
2644
|
if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) {
|
|
1050
|
-
|
|
1051
|
-
if (transportIndex > this.transports.length - 1) {
|
|
1052
|
-
transportIndex = 0;
|
|
1053
|
-
}
|
|
2645
|
+
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
1054
2646
|
}
|
|
1055
2647
|
|
|
1056
|
-
await this.queue.update(task, {
|
|
1057
|
-
|
|
2648
|
+
const released = await this.queue.update(task, __withLease(task, {
|
|
2649
|
+
isSending: false,
|
|
2650
|
+
sendingAt: 0,
|
|
1058
2651
|
sendAt: Date.now() + this.retryDelay,
|
|
1059
2652
|
transport: transportIndex,
|
|
1060
|
-
});
|
|
2653
|
+
}));
|
|
1061
2654
|
|
|
1062
|
-
|
|
2655
|
+
if (!released) {
|
|
2656
|
+
this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
this.__debug(`[private handleError] Next re-send attempt at ${new Date(Date.now() + this.retryDelay)}: #${task.tries}/${this.maxTries}, transport #${transportIndex} to: `, task.mailOptions[0].to, error);
|
|
1063
2661
|
}
|
|
1064
2662
|
|
|
1065
2663
|
/**
|
|
1066
2664
|
* @async
|
|
2665
|
+
* @internal
|
|
1067
2666
|
* @memberOf MailTime
|
|
1068
2667
|
* @name ___addToQueue
|
|
1069
2668
|
* @description Prepare task's object and push to the queue
|
|
1070
|
-
* @param
|
|
1071
|
-
* @param opts.sendAt {number} - When email should be sent
|
|
1072
|
-
* @param opts.template {string} - Email-specific template
|
|
1073
|
-
* @param opts.mailOptions {object} - MailOptions according to NodeMailer lib
|
|
1074
|
-
* @param opts.concatSubject {string} - Email subject used when sending concatenated email
|
|
2669
|
+
* @param {{ sendAt: number, template: string | false, mailOptions: MailTimeMailOptions, concatSubject: string | false }} opts - Email options
|
|
1075
2670
|
* @returns {Promise<string>} message uuid
|
|
1076
2671
|
*/
|
|
1077
2672
|
async ___addToQueue(opts) {
|
|
1078
|
-
this.
|
|
2673
|
+
this.__debug('[private addToQueue]', opts);
|
|
2674
|
+
let transportIndex = this.transport;
|
|
2675
|
+
if (this.strategy === 'balancer' && this.transports.length > 0) {
|
|
2676
|
+
transportIndex = this.___nextHealthyTransport(this.transport);
|
|
2677
|
+
this.transport = transportIndex;
|
|
2678
|
+
}
|
|
1079
2679
|
const task = {
|
|
1080
2680
|
uuid: crypto.randomUUID(),
|
|
1081
2681
|
tries: 0,
|
|
1082
2682
|
isSent: false,
|
|
1083
2683
|
sendAt: opts.sendAt,
|
|
1084
2684
|
isFailed: false,
|
|
2685
|
+
isSending: false,
|
|
2686
|
+
sendingAt: 0,
|
|
1085
2687
|
template: opts.template,
|
|
1086
|
-
transport:
|
|
2688
|
+
transport: transportIndex,
|
|
1087
2689
|
isCancelled: false,
|
|
1088
2690
|
mailOptions: [opts.mailOptions],
|
|
1089
2691
|
concatSubject: opts.concatSubject,
|
|
@@ -1098,30 +2700,32 @@ class MailTime {
|
|
|
1098
2700
|
}
|
|
1099
2701
|
|
|
1100
2702
|
/**
|
|
2703
|
+
* @internal
|
|
1101
2704
|
* @memberOf MailTime
|
|
1102
2705
|
* @name ___render
|
|
1103
|
-
* @description Render
|
|
1104
|
-
* @param
|
|
1105
|
-
* @param
|
|
2706
|
+
* @description Render Mustache-like placeholders
|
|
2707
|
+
* @param {string} _string - Template with Mustache-like placeholders
|
|
2708
|
+
* @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
|
|
1106
2709
|
* @returns {string}
|
|
1107
2710
|
*/
|
|
1108
2711
|
___render(_string, replacements) {
|
|
1109
|
-
let i;
|
|
1110
2712
|
let string = _string;
|
|
1111
|
-
const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9
|
|
2713
|
+
const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
|
|
1112
2714
|
if (matchHTML) {
|
|
1113
|
-
for (i = 0; i < matchHTML.length; i++) {
|
|
1114
|
-
|
|
1115
|
-
|
|
2715
|
+
for (let i = 0; i < matchHTML.length; i++) {
|
|
2716
|
+
const key = matchHTML[i].slice(3, -3).trim();
|
|
2717
|
+
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
2718
|
+
string = string.replace(matchHTML[i], `${replacements[key]}`);
|
|
1116
2719
|
}
|
|
1117
2720
|
}
|
|
1118
2721
|
}
|
|
1119
2722
|
|
|
1120
|
-
const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9
|
|
2723
|
+
const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9\-_]+)\s?\}{2}/g);
|
|
1121
2724
|
if (matchStr) {
|
|
1122
|
-
for (i = 0; i < matchStr.length; i++) {
|
|
1123
|
-
|
|
1124
|
-
|
|
2725
|
+
for (let i = 0; i < matchStr.length; i++) {
|
|
2726
|
+
const key = matchStr[i].slice(2, -2).trim();
|
|
2727
|
+
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
2728
|
+
string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
|
|
1125
2729
|
}
|
|
1126
2730
|
}
|
|
1127
2731
|
}
|
|
@@ -1129,157 +2733,491 @@ class MailTime {
|
|
|
1129
2733
|
}
|
|
1130
2734
|
|
|
1131
2735
|
/**
|
|
2736
|
+
* @internal
|
|
1132
2737
|
* @memberOf MailTime
|
|
1133
2738
|
* @name ___compileMailOpts
|
|
1134
2739
|
* @description Run various checks, compile options, and render template
|
|
1135
|
-
* @param
|
|
1136
|
-
* @param
|
|
1137
|
-
* @returns {
|
|
2740
|
+
* @param {MailTimeTransport} transport - Current transport
|
|
2741
|
+
* @param {MailTimeTask} task - Email task record from Storage
|
|
2742
|
+
* @returns {MailTimeMailOptions}
|
|
1138
2743
|
*/
|
|
1139
2744
|
___compileMailOpts(transport, task) {
|
|
1140
|
-
let compiledOpts = {};
|
|
1141
|
-
|
|
1142
2745
|
if (!transport) {
|
|
1143
2746
|
throw new Error('[mail-time] [sendMail] [___compileMailOpts] {transport} is not available or misconfigured!');
|
|
1144
2747
|
}
|
|
1145
2748
|
|
|
1146
|
-
|
|
1147
|
-
|
|
2749
|
+
let compiledOpts = {};
|
|
2750
|
+
|
|
2751
|
+
if (isPlainObject(transport._options) && isPlainObject(transport._options.mailOptions)) {
|
|
2752
|
+
compiledOpts = deepMerge(compiledOpts, transport._options.mailOptions);
|
|
1148
2753
|
}
|
|
1149
2754
|
|
|
1150
|
-
if (transport.options &&
|
|
1151
|
-
compiledOpts =
|
|
2755
|
+
if (isPlainObject(transport.options) && isPlainObject(transport.options.mailOptions)) {
|
|
2756
|
+
compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
|
|
1152
2757
|
}
|
|
1153
2758
|
|
|
1154
|
-
compiledOpts
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
2759
|
+
compiledOpts.html ??= '';
|
|
2760
|
+
compiledOpts.text ??= '';
|
|
2761
|
+
compiledOpts.subject ??= '';
|
|
2762
|
+
|
|
2763
|
+
const mailOptionsList = task.mailOptions || [];
|
|
2764
|
+
const isMulti = mailOptionsList.length > 1;
|
|
2765
|
+
|
|
2766
|
+
for (let i = 0; i < mailOptionsList.length; i++) {
|
|
2767
|
+
const mailOption = { ...mailOptionsList[i] };
|
|
1159
2768
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
if (
|
|
1163
|
-
compiledOpts.html += this.___render(this.concatDelimiter,
|
|
2769
|
+
if (mailOption.html) {
|
|
2770
|
+
const rendered = this.___render(mailOption.html, mailOption);
|
|
2771
|
+
if (isMulti) {
|
|
2772
|
+
compiledOpts.html += this.___render(this.concatDelimiter, mailOption) + rendered;
|
|
1164
2773
|
} else {
|
|
1165
|
-
compiledOpts.html =
|
|
2774
|
+
compiledOpts.html = rendered;
|
|
1166
2775
|
}
|
|
1167
|
-
delete
|
|
2776
|
+
delete mailOption.html;
|
|
1168
2777
|
}
|
|
1169
2778
|
|
|
1170
|
-
if (
|
|
1171
|
-
|
|
1172
|
-
|
|
2779
|
+
if (mailOption.text) {
|
|
2780
|
+
const rendered = this.___render(mailOption.text, mailOption);
|
|
2781
|
+
if (isMulti) {
|
|
2782
|
+
compiledOpts.text += '\r\n' + rendered;
|
|
1173
2783
|
} else {
|
|
1174
|
-
compiledOpts.text =
|
|
2784
|
+
compiledOpts.text = rendered;
|
|
1175
2785
|
}
|
|
1176
|
-
delete
|
|
2786
|
+
delete mailOption.text;
|
|
1177
2787
|
}
|
|
1178
2788
|
|
|
1179
|
-
compiledOpts =
|
|
2789
|
+
compiledOpts = deepMerge(compiledOpts, mailOption);
|
|
1180
2790
|
}
|
|
1181
2791
|
|
|
1182
2792
|
if (compiledOpts.html && (task.template || this.template)) {
|
|
1183
2793
|
compiledOpts.html = this.___render((task.template || this.template), compiledOpts);
|
|
1184
2794
|
}
|
|
1185
2795
|
|
|
1186
|
-
if (
|
|
1187
|
-
|
|
2796
|
+
if (isMulti) {
|
|
2797
|
+
const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
|
|
2798
|
+
compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
|
|
1188
2799
|
}
|
|
1189
2800
|
|
|
1190
2801
|
if (!compiledOpts.from && this.from) {
|
|
1191
2802
|
compiledOpts.from = this.from(transport);
|
|
1192
2803
|
}
|
|
1193
2804
|
|
|
2805
|
+
const acceptedSet = collectAcceptedSet(task);
|
|
2806
|
+
if (acceptedSet.size > 0) {
|
|
2807
|
+
compiledOpts.to = filterAddressField(compiledOpts.to, acceptedSet);
|
|
2808
|
+
compiledOpts.cc = filterAddressField(compiledOpts.cc, acceptedSet);
|
|
2809
|
+
compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
|
|
2810
|
+
}
|
|
2811
|
+
|
|
1194
2812
|
return compiledOpts;
|
|
1195
2813
|
}
|
|
1196
2814
|
|
|
2815
|
+
/**
|
|
2816
|
+
* @internal
|
|
2817
|
+
* @memberOf MailTime
|
|
2818
|
+
* @name ___trackAcceptedRecipients
|
|
2819
|
+
* @description Append newly-accepted addresses to each mailOption's `accepted` list
|
|
2820
|
+
* @param {MailTimeTask} task - Email task record from Storage
|
|
2821
|
+
* @param {string[]} acceptedAddrs - Lowercased addresses confirmed by transport
|
|
2822
|
+
* @returns {void}
|
|
2823
|
+
*/
|
|
2824
|
+
___trackAcceptedRecipients(task, acceptedAddrs) {
|
|
2825
|
+
if (!Array.isArray(task?.mailOptions) || acceptedAddrs.length === 0) {
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
const acceptedSet = new Set(acceptedAddrs);
|
|
2829
|
+
for (const mo of task.mailOptions) {
|
|
2830
|
+
if (!Array.isArray(mo.accepted)) {
|
|
2831
|
+
mo.accepted = [];
|
|
2832
|
+
}
|
|
2833
|
+
const moRecipients = new Set(mailOptionRecipients(mo));
|
|
2834
|
+
if (moRecipients.size === 0) {
|
|
2835
|
+
continue;
|
|
2836
|
+
}
|
|
2837
|
+
const alreadyAccepted = new Set(mo.accepted.map((a) => typeof a === 'string' ? a.toLowerCase() : a));
|
|
2838
|
+
for (const addr of acceptedSet) {
|
|
2839
|
+
if (moRecipients.has(addr) && !alreadyAccepted.has(addr)) {
|
|
2840
|
+
mo.accepted.push(addr);
|
|
2841
|
+
alreadyAccepted.add(addr);
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
/**
|
|
2848
|
+
* @internal
|
|
2849
|
+
* @memberOf MailTime
|
|
2850
|
+
* @name ___finalizeRejected
|
|
2851
|
+
* @description Populate each mailOption's `rejected` list with addresses that never delivered
|
|
2852
|
+
* @param {MailTimeTask} task - Email task record from Storage
|
|
2853
|
+
* @param {object} info - Info object from the last nodemailer attempt
|
|
2854
|
+
* @returns {void}
|
|
2855
|
+
*/
|
|
2856
|
+
___finalizeRejected(task, info) {
|
|
2857
|
+
if (!Array.isArray(task?.mailOptions)) {
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
const errorMap = buildRejectionErrorMap(info);
|
|
2861
|
+
for (const mo of task.mailOptions) {
|
|
2862
|
+
const moAccepted = new Set((mo.accepted || []).map((a) => typeof a === 'string' ? a.toLowerCase() : a));
|
|
2863
|
+
const seen = new Set();
|
|
2864
|
+
const rejected = [];
|
|
2865
|
+
for (const addr of mailOptionRecipients(mo)) {
|
|
2866
|
+
if (moAccepted.has(addr) || seen.has(addr)) {
|
|
2867
|
+
continue;
|
|
2868
|
+
}
|
|
2869
|
+
seen.add(addr);
|
|
2870
|
+
rejected.push({
|
|
2871
|
+
address: addr,
|
|
2872
|
+
error: errorMap.get(addr) || 'Recipient rejected by transport',
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
mo.rejected = rejected;
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
/**
|
|
2880
|
+
* @async
|
|
2881
|
+
* @internal
|
|
2882
|
+
* @memberOf MailTime
|
|
2883
|
+
* @name ___dispatch
|
|
2884
|
+
* @description Queue full-lifecycle send for `task` under the bounded send pool. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
|
|
2885
|
+
* @param {MailTimeTask} task - email's task object from Storage
|
|
2886
|
+
* @returns {Promise<void 0>}
|
|
2887
|
+
*/
|
|
2888
|
+
async ___dispatch(task) {
|
|
2889
|
+
if (this.__isDestroyed) {
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
2892
|
+
if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
if (this.__inFlight.has(task.uuid)) {
|
|
2896
|
+
this.__debug('[private dispatch] already in-flight on this instance, skipping', task.uuid);
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
this.__inFlight.add(task.uuid);
|
|
2900
|
+
await this.__pool.dispatch(async () => {
|
|
2901
|
+
try {
|
|
2902
|
+
if (this.__isDestroyed) {
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
await this.___send(task);
|
|
2906
|
+
} finally {
|
|
2907
|
+
this.__inFlight.delete(task.uuid);
|
|
2908
|
+
}
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
|
|
1197
2912
|
/**
|
|
1198
2913
|
* @async
|
|
2914
|
+
* @internal
|
|
1199
2915
|
* @memberOf MailTime
|
|
1200
2916
|
* @name ___send
|
|
1201
|
-
* @description send
|
|
1202
|
-
* @param
|
|
2917
|
+
* @description Full send lifecycle for a single task: atomic claim (`isSending=true, sendingAt=now, tries=tries+1`), SMTP roundtrip, completion. Returns when the lifecycle ends.
|
|
2918
|
+
* @param {MailTimeTask} task - email's task object from Storage
|
|
1203
2919
|
* @returns {Promise<void 0>}
|
|
1204
2920
|
*/
|
|
1205
2921
|
async ___send(task) {
|
|
1206
|
-
this.
|
|
2922
|
+
this.__debug('[private send]', task);
|
|
1207
2923
|
try {
|
|
1208
|
-
if (task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
2924
|
+
if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
1209
2925
|
return;
|
|
1210
2926
|
}
|
|
1211
2927
|
|
|
1212
|
-
task.tries
|
|
1213
|
-
const
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
2928
|
+
const tries = task.tries + 1;
|
|
2929
|
+
const sendingAt = Date.now();
|
|
2930
|
+
let isClaimed = false;
|
|
2931
|
+
try {
|
|
2932
|
+
isClaimed = await this.queue.update(task, {
|
|
2933
|
+
isSending: true,
|
|
2934
|
+
sendingAt,
|
|
2935
|
+
tries,
|
|
2936
|
+
});
|
|
2937
|
+
} catch (claimError) {
|
|
2938
|
+
logError('[private send] [claim] storage error during atomic claim', claimError);
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
1217
2941
|
|
|
1218
|
-
if (!
|
|
1219
|
-
|
|
2942
|
+
if (!isClaimed) {
|
|
2943
|
+
this.__debug('[private send] [queue.update] Stale claim, skipping', task.uuid);
|
|
1220
2944
|
return;
|
|
1221
2945
|
}
|
|
1222
2946
|
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
transport = this.transports[transportIndex];
|
|
1232
|
-
} else {
|
|
1233
|
-
transportIndex = task.transport;
|
|
1234
|
-
transport = this.transports[transportIndex];
|
|
2947
|
+
task.tries = tries;
|
|
2948
|
+
task.isSending = true;
|
|
2949
|
+
task.sendingAt = sendingAt;
|
|
2950
|
+
|
|
2951
|
+
let transportIndex = task.transport;
|
|
2952
|
+
if (!this.___isHealthyTransport(transportIndex)) {
|
|
2953
|
+
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
2954
|
+
task.transport = transportIndex;
|
|
1235
2955
|
}
|
|
2956
|
+
const transport = this.transports[transportIndex];
|
|
1236
2957
|
|
|
1237
2958
|
const compiledOpts = this.___compileMailOpts(transport, task);
|
|
1238
2959
|
|
|
1239
2960
|
await new Promise((resolve) => {
|
|
1240
2961
|
transport.sendMail(compiledOpts, async (error, info) => {
|
|
1241
|
-
this.
|
|
1242
|
-
|
|
1243
|
-
await this.___handleError(task, error, info);
|
|
2962
|
+
if (this.__abortInFlight) {
|
|
2963
|
+
this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
|
|
1244
2964
|
resolve();
|
|
1245
2965
|
return;
|
|
1246
2966
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
2967
|
+
this.__debug('[private send] [sending]', { error, info });
|
|
2968
|
+
try {
|
|
2969
|
+
if (error) {
|
|
2970
|
+
await this.___handleError(task, error, info);
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
const acceptedAddrs = Array.isArray(info?.accepted)
|
|
2975
|
+
? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
|
|
2976
|
+
: [];
|
|
2977
|
+
|
|
2978
|
+
if (acceptedAddrs.length === 0) {
|
|
2979
|
+
await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
this.___trackAcceptedRecipients(task, acceptedAddrs);
|
|
2984
|
+
|
|
2985
|
+
const allRecipients = collectAllRecipients(task);
|
|
2986
|
+
const allAccepted = collectAcceptedSet(task);
|
|
2987
|
+
let isFullyDelivered = true;
|
|
2988
|
+
for (const addr of allRecipients) {
|
|
2989
|
+
if (!allAccepted.has(addr)) {
|
|
2990
|
+
isFullyDelivered = false;
|
|
2991
|
+
break;
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
if (isFullyDelivered) {
|
|
2996
|
+
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
2997
|
+
|
|
2998
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
2999
|
+
const leaseUpdate = __withLease(task, {
|
|
3000
|
+
isSent: true,
|
|
3001
|
+
isSending: false,
|
|
3002
|
+
sendingAt: 0,
|
|
3003
|
+
mailOptions: task.mailOptions,
|
|
3004
|
+
});
|
|
3005
|
+
|
|
3006
|
+
let completed = false;
|
|
3007
|
+
if (!this.keepHistory) {
|
|
3008
|
+
completed = await this.queue.remove(task, leaseRemove);
|
|
3009
|
+
} else {
|
|
3010
|
+
completed = await this.queue.update(task, leaseUpdate);
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
if (!completed) {
|
|
3014
|
+
this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
|
|
3019
|
+
task.isSent = true;
|
|
3020
|
+
task.isSending = false;
|
|
3021
|
+
task.sendingAt = 0;
|
|
3022
|
+
this.onSent(task, info);
|
|
3023
|
+
return;
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
if (task.tries >= this.maxTries) {
|
|
3027
|
+
this.___finalizeRejected(task, info);
|
|
3028
|
+
|
|
3029
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
3030
|
+
const leaseUpdate = __withLease(task, {
|
|
3031
|
+
isSent: false,
|
|
3032
|
+
isFailed: true,
|
|
3033
|
+
isSending: false,
|
|
3034
|
+
sendingAt: 0,
|
|
3035
|
+
mailOptions: task.mailOptions,
|
|
3036
|
+
});
|
|
3037
|
+
|
|
3038
|
+
let finalized = false;
|
|
3039
|
+
if (!this.keepHistory) {
|
|
3040
|
+
finalized = await this.queue.remove(task, leaseRemove);
|
|
3041
|
+
} else {
|
|
3042
|
+
finalized = await this.queue.update(task, leaseUpdate);
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
if (!finalized) {
|
|
3046
|
+
this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
|
|
3051
|
+
task.isSent = false;
|
|
3052
|
+
task.isFailed = true;
|
|
3053
|
+
task.isSending = false;
|
|
3054
|
+
|
|
3055
|
+
const rejectedAddrs = [];
|
|
3056
|
+
for (const mo of task.mailOptions) {
|
|
3057
|
+
for (const r of (mo.rejected || [])) {
|
|
3058
|
+
rejectedAddrs.push(r.address);
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
|
|
3062
|
+
this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
|
|
3063
|
+
this.onError(partialError, task, info);
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
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 {
|
|
1250
3080
|
resolve();
|
|
1251
|
-
return;
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
this._debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
1255
|
-
|
|
1256
|
-
if (!this.keepHistory) {
|
|
1257
|
-
await this.queue.remove(task);
|
|
1258
3081
|
}
|
|
1259
|
-
|
|
1260
|
-
task.isSent = true;
|
|
1261
|
-
this.onSent(task, info);
|
|
1262
|
-
resolve();
|
|
1263
3082
|
});
|
|
1264
3083
|
});
|
|
1265
3084
|
} catch (e) {
|
|
3085
|
+
if (this.__abortInFlight) {
|
|
3086
|
+
this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
1266
3089
|
logError('Exception during runtime:', e);
|
|
1267
|
-
this.___handleError(task, e, {});
|
|
3090
|
+
await this.___handleError(task, e, {});
|
|
1268
3091
|
}
|
|
1269
3092
|
}
|
|
1270
3093
|
|
|
1271
3094
|
/**
|
|
3095
|
+
* @internal
|
|
1272
3096
|
* @memberOf MailTime
|
|
1273
3097
|
* @name ___iterate
|
|
1274
|
-
* @description
|
|
1275
|
-
* @returns {Promise}
|
|
3098
|
+
* @description JoSk handler — claim and dispatch due tasks. Returns as soon as the scan finishes so the JoSk lease is released; in-flight SMTP work continues in the background pool.
|
|
3099
|
+
* @returns {Promise<void>|void}
|
|
1276
3100
|
*/
|
|
1277
3101
|
async ___iterate() {
|
|
1278
|
-
this.
|
|
1279
|
-
|
|
3102
|
+
this.__debug('[private iterate]');
|
|
3103
|
+
if (this.__isDestroyed || this.__isPaused) {
|
|
3104
|
+
return;
|
|
3105
|
+
}
|
|
3106
|
+
const limit = this.mode === 'one' ? 1 : Infinity;
|
|
3107
|
+
return await this.queue.iterate({
|
|
3108
|
+
limit,
|
|
3109
|
+
sendingTimeout: this.sendingTimeout,
|
|
3110
|
+
});
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
/**
|
|
3114
|
+
* @async
|
|
3115
|
+
* @internal
|
|
3116
|
+
* @memberOf MailTime
|
|
3117
|
+
* @name ___ready
|
|
3118
|
+
* @description Internal storage readiness gate
|
|
3119
|
+
* @returns {Promise<MailTime>}
|
|
3120
|
+
*/
|
|
3121
|
+
async ___ready() {
|
|
3122
|
+
if (typeof this.queue.ready === 'function') {
|
|
3123
|
+
await this.queue.ready();
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
if (this.__schedulerTimer) {
|
|
3127
|
+
await this.__schedulerTimer;
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
const pingResult = await this.ping();
|
|
3131
|
+
if (pingResult.status !== 'OK') {
|
|
3132
|
+
throw new Error('[mail-time] [MailTime#ready] can not connect to storage, make sure it is available and properly configured', { cause: pingResult.error });
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
if (this.type === 'server' && this.verifyTransports && this.transports.length > 0) {
|
|
3136
|
+
await this.___verifyTransports();
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
return this;
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
/**
|
|
3143
|
+
* @async
|
|
3144
|
+
* @internal
|
|
3145
|
+
* @memberOf MailTime
|
|
3146
|
+
* @name ___verifyTransports
|
|
3147
|
+
* @description Probe each transport's `verify()` once at startup. Failing transports are marked unusable and skipped during rotation; the failure is surfaced through `onError(error, null, { transportIndex, phase: 'verify' })`. Throws if every transport fails — there is nothing left that could deliver.
|
|
3148
|
+
* @returns {Promise<void>}
|
|
3149
|
+
*/
|
|
3150
|
+
async ___verifyTransports() {
|
|
3151
|
+
this.__debug('[private verifyTransports]');
|
|
3152
|
+
const results = await Promise.all(this.transports.map(async (transport, index) => {
|
|
3153
|
+
if (!transport || typeof transport.verify !== 'function') {
|
|
3154
|
+
return { index, ok: true };
|
|
3155
|
+
}
|
|
3156
|
+
try {
|
|
3157
|
+
await Promise.resolve(transport.verify());
|
|
3158
|
+
return { index, ok: true };
|
|
3159
|
+
} catch (error) {
|
|
3160
|
+
return { index, ok: false, error };
|
|
3161
|
+
}
|
|
3162
|
+
}));
|
|
3163
|
+
|
|
3164
|
+
for (const r of results) {
|
|
3165
|
+
if (r.ok) {
|
|
3166
|
+
continue;
|
|
3167
|
+
}
|
|
3168
|
+
this.__unhealthyTransports.add(r.index);
|
|
3169
|
+
logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
|
|
3170
|
+
this.onError(r.error, null, { transportIndex: r.index, phase: 'verify' });
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
if (this.__unhealthyTransports.size === this.transports.length) {
|
|
3174
|
+
throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
if (this.__unhealthyTransports.has(this.transport)) {
|
|
3178
|
+
this.transport = this.___nextHealthyTransport(this.transport);
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
/**
|
|
3183
|
+
* @internal
|
|
3184
|
+
* @memberOf MailTime
|
|
3185
|
+
* @name ___isHealthyTransport
|
|
3186
|
+
* @description Return true when the transport at `index` has not been marked unusable by verification.
|
|
3187
|
+
* @param {number} index
|
|
3188
|
+
* @returns {boolean}
|
|
3189
|
+
*/
|
|
3190
|
+
___isHealthyTransport(index) {
|
|
3191
|
+
return !this.__unhealthyTransports.has(index);
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
/**
|
|
3195
|
+
* @internal
|
|
3196
|
+
* @memberOf MailTime
|
|
3197
|
+
* @name ___nextHealthyTransport
|
|
3198
|
+
* @description Advance at least one position from `fromIdx` and return the next healthy transport index (wrapping). Falls back to `fromIdx` if no healthy transport exists.
|
|
3199
|
+
* @param {number} fromIdx
|
|
3200
|
+
* @returns {number}
|
|
3201
|
+
*/
|
|
3202
|
+
___nextHealthyTransport(fromIdx) {
|
|
3203
|
+
if (this.transports.length === 0) {
|
|
3204
|
+
return fromIdx;
|
|
3205
|
+
}
|
|
3206
|
+
let next = fromIdx;
|
|
3207
|
+
for (let i = 0; i < this.transports.length; i++) {
|
|
3208
|
+
next = (next + 1) % this.transports.length;
|
|
3209
|
+
if (this.___isHealthyTransport(next)) {
|
|
3210
|
+
return next;
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
return fromIdx;
|
|
1280
3214
|
}
|
|
1281
3215
|
}
|
|
1282
3216
|
|
|
1283
3217
|
exports.MailTime = MailTime;
|
|
1284
3218
|
exports.MongoQueue = MongoQueue;
|
|
3219
|
+
exports.PostgresQueue = PostgresQueue;
|
|
1285
3220
|
exports.RedisQueue = RedisQueue;
|
|
3221
|
+
exports.mailTimePreset = mailTimePreset;
|
|
3222
|
+
exports.presetNames = presetNames;
|
|
3223
|
+
exports.presets = presets;
|