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