mail-time 1.4.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 +367 -301
- package/adapters/mongo.d.ts +118 -0
- package/adapters/mongo.js +431 -0
- package/adapters/postgres.d.ts +100 -0
- package/adapters/postgres.js +520 -0
- package/adapters/redis.d.ts +105 -0
- package/adapters/redis.js +565 -0
- package/helpers.js +217 -0
- package/index.cjs +2862 -462
- package/index.d.cts +300 -0
- package/index.d.ts +300 -0
- package/index.js +1002 -475
- package/package.json +66 -16
- package/presets.d.ts +87 -0
- package/presets.js +193 -0
package/index.cjs
CHANGED
|
@@ -1,91 +1,69 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
3
|
+
const josk = require('josk');
|
|
4
|
+
const node_crypto = require('node:crypto');
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
|
|
6
|
+
const hasOwn = Object.prototype.hasOwnProperty;
|
|
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) => {
|
|
15
|
+
console.error('[ERROR] [mail-time]', `${new Date()}`, ...args);
|
|
14
16
|
};
|
|
15
17
|
|
|
16
|
-
const
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
console.trace();
|
|
18
|
+
const isPlainObject = (value) => {
|
|
19
|
+
if (value === null || typeof value !== 'object') {
|
|
20
|
+
return false;
|
|
20
21
|
}
|
|
22
|
+
const proto = Object.getPrototypeOf(value);
|
|
23
|
+
return proto === null || proto === Object.prototype;
|
|
21
24
|
};
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* @returns {void 0}
|
|
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.
|
|
30
32
|
*/
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
if (e.code === 85) {
|
|
36
|
-
let indexName;
|
|
37
|
-
const indexes = await collection.indexes();
|
|
38
|
-
for (const index of indexes) {
|
|
39
|
-
let drop = true;
|
|
40
|
-
for (const indexKey of Object.keys(keys)) {
|
|
41
|
-
if (typeof index.key[indexKey] === 'undefined') {
|
|
42
|
-
drop = false;
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
33
|
+
const deepMerge = (target, source) => {
|
|
34
|
+
if (!isPlainObject(source)) {
|
|
35
|
+
return target;
|
|
36
|
+
}
|
|
46
37
|
|
|
47
|
-
|
|
48
|
-
if (typeof keys[indexKey] === 'undefined') {
|
|
49
|
-
drop = false;
|
|
50
|
-
break;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
38
|
+
const out = isPlainObject(target) ? { ...target } : {};
|
|
53
39
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
}
|
|
40
|
+
for (const key of Object.keys(source)) {
|
|
41
|
+
const sVal = source[key];
|
|
42
|
+
const tVal = out[key];
|
|
59
43
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
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);
|
|
64
48
|
} else {
|
|
65
|
-
|
|
49
|
+
out[key] = sVal;
|
|
66
50
|
}
|
|
67
51
|
}
|
|
52
|
+
|
|
53
|
+
return out;
|
|
68
54
|
};
|
|
69
55
|
|
|
70
56
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* @param {mix} a
|
|
75
|
-
* @param {mix} b
|
|
76
|
-
* @returns {boolean}
|
|
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.
|
|
77
60
|
*/
|
|
78
61
|
const equals = (a, b) => {
|
|
79
|
-
let i;
|
|
80
62
|
if (a === b) {
|
|
81
63
|
return true;
|
|
82
64
|
}
|
|
83
65
|
|
|
84
|
-
if (!a || !b) {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (!(typeof a === 'object' && typeof b === 'object')) {
|
|
66
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') {
|
|
89
67
|
return false;
|
|
90
68
|
}
|
|
91
69
|
|
|
@@ -93,543 +71,2505 @@ const equals = (a, b) => {
|
|
|
93
71
|
return a.valueOf() === b.valueOf();
|
|
94
72
|
}
|
|
95
73
|
|
|
96
|
-
if (a
|
|
97
|
-
if (!(b
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (a.length !== b.length) {
|
|
74
|
+
if (Array.isArray(a)) {
|
|
75
|
+
if (!Array.isArray(b) || a.length !== b.length) {
|
|
102
76
|
return false;
|
|
103
77
|
}
|
|
104
78
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
result = true;
|
|
113
|
-
_a.splice(i, 1);
|
|
114
|
-
_b.splice(j, 1);
|
|
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;
|
|
115
86
|
break;
|
|
116
87
|
}
|
|
117
88
|
}
|
|
118
|
-
|
|
119
|
-
if (!result) {
|
|
89
|
+
if (!found) {
|
|
120
90
|
return false;
|
|
121
91
|
}
|
|
122
92
|
}
|
|
123
93
|
return true;
|
|
124
94
|
}
|
|
125
95
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
96
|
+
if (Array.isArray(b)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const aKeys = Object.keys(a);
|
|
101
|
+
const bKeys = Object.keys(b);
|
|
130
102
|
|
|
131
|
-
|
|
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])) {
|
|
132
110
|
return false;
|
|
133
111
|
}
|
|
112
|
+
}
|
|
134
113
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
139
116
|
|
|
140
|
-
|
|
141
|
-
|
|
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);
|
|
142
149
|
}
|
|
143
150
|
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
const single = extractEmail(field);
|
|
154
|
+
return single ? [single] : [];
|
|
155
|
+
};
|
|
144
156
|
|
|
145
|
-
|
|
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;
|
|
146
179
|
}
|
|
147
|
-
return
|
|
180
|
+
return field;
|
|
148
181
|
};
|
|
149
182
|
|
|
150
|
-
|
|
183
|
+
const isSendClaimUpdate = (updateObj) => {
|
|
184
|
+
return updateObj && updateObj.isSending === true && typeof updateObj.tries === 'number';
|
|
185
|
+
};
|
|
151
186
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (!opts || typeof opts !== 'object' || opts === null) {
|
|
156
|
-
throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor');
|
|
157
|
-
}
|
|
187
|
+
const isSendLeaseGuardedUpdate = (updateObj) => {
|
|
188
|
+
return updateObj && typeof updateObj.leaseTries === 'number' && typeof updateObj.leaseSendingAt === 'number';
|
|
189
|
+
};
|
|
158
190
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
191
|
+
const isAppendMailOptionUpdate = (updateObj) => {
|
|
192
|
+
return updateObj && updateObj.appendMailOption !== void 0;
|
|
193
|
+
};
|
|
162
194
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
this.keepHistory = opts.keepHistory || false;
|
|
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
|
+
};
|
|
172
203
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
204
|
+
const isSendLeaseRemove = (opts) => {
|
|
205
|
+
return opts && typeof opts.leaseTries === 'number' && typeof opts.leaseSendingAt === 'number';
|
|
206
|
+
};
|
|
176
207
|
|
|
177
|
-
|
|
178
|
-
this.interval = 3072;
|
|
179
|
-
}
|
|
208
|
+
const DEFAULT_PREFIX$2 = '';
|
|
180
209
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
*/
|
|
184
222
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
223
|
+
/**
|
|
224
|
+
* @typedef {object} Db
|
|
225
|
+
* @property {(name: string) => MongoCollection} collection
|
|
226
|
+
* @property {(cmd: object) => Promise<{ ok?: number }>} command
|
|
227
|
+
*/
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @typedef {object} MongoQueueOption
|
|
231
|
+
* @property {Db} db
|
|
232
|
+
* @property {string} [prefix]
|
|
233
|
+
*/
|
|
234
|
+
|
|
235
|
+
/** @internal */
|
|
236
|
+
const ensureIndex = async (collection, keys, opts) => {
|
|
237
|
+
try {
|
|
238
|
+
await collection.createIndex(keys, opts);
|
|
239
|
+
} catch (e) {
|
|
240
|
+
if (e?.code === 85) {
|
|
241
|
+
let indexName;
|
|
242
|
+
const indexes = await collection.indexes();
|
|
243
|
+
const keyNames = Object.keys(keys);
|
|
244
|
+
for (const index of indexes) {
|
|
245
|
+
const indexKeys = Object.keys(index.key);
|
|
246
|
+
if (indexKeys.length !== keyNames.length) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
let match = true;
|
|
250
|
+
for (const k of keyNames) {
|
|
251
|
+
if (typeof index.key[k] === 'undefined') {
|
|
252
|
+
match = false;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (match) {
|
|
257
|
+
indexName = index.name;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
194
260
|
}
|
|
195
261
|
|
|
196
|
-
if (
|
|
197
|
-
|
|
262
|
+
if (indexName) {
|
|
263
|
+
await collection.dropIndex(indexName);
|
|
264
|
+
await collection.createIndex(keys, opts);
|
|
198
265
|
}
|
|
266
|
+
} else {
|
|
267
|
+
logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection?.collectionName || 'MongoDB'}" collection`, { keys, opts, details: e });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
199
271
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
_debug(this.debug, 'DEBUG ON {debug: true}');
|
|
213
|
-
_debug(this.debug, `INITIALIZING [type: ${this.type}]`);
|
|
214
|
-
_debug(this.debug, `INITIALIZING [strategy: ${this.strategy}]`);
|
|
215
|
-
_debug(this.debug, `INITIALIZING [prefix: ${this.prefix}]`);
|
|
216
|
-
_debug(this.debug, `INITIALIZING [maxTries: ${this.maxTries}]`);
|
|
217
|
-
_debug(this.debug, `INITIALIZING [failsToNext: ${this.failsToNext}]`);
|
|
218
|
-
|
|
219
|
-
if (opts.type === 'server' && (this.transports.constructor !== Array || !this.transports.length)) {
|
|
220
|
-
throw new Error('[mail-time] transports is required and must be an Array, like returned from `nodemailer.createTransport`');
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
this.collection = opts.db.collection(`__mailTimeQueue__${this.prefix}`);
|
|
224
|
-
ensureIndex(this.collection, { isSent: 1, to: 1, sendAt: 1 });
|
|
225
|
-
ensureIndex(this.collection, { isSent: 1, sendAt: 1, tries: 1 }, { background: true });
|
|
226
|
-
|
|
227
|
-
// MongoDB Collection Schema:
|
|
228
|
-
// _id
|
|
229
|
-
// to {String|[String]}
|
|
230
|
-
// tries {Number} - qty of send attempts
|
|
231
|
-
// sendAt {Date} - When letter should be sent
|
|
232
|
-
// isSent {Boolean} - Email status
|
|
233
|
-
// template {String} - Template for this email
|
|
234
|
-
// transport {Number} - Last used transport
|
|
235
|
-
// concatSubject {String|Boolean} - Email concatenation subject
|
|
236
|
-
// ---
|
|
237
|
-
// mailOptions {[Object]} - Array of nodeMailer's `mailOptions`
|
|
238
|
-
// mailOptions.to {String|Array} - [REQUIRED]
|
|
239
|
-
// mailOptions.from {String}
|
|
240
|
-
// mailOptions.text {String|Boolean}
|
|
241
|
-
// mailOptions.html (String)
|
|
242
|
-
// mailOptions.subject {String}
|
|
243
|
-
// mailOptions.Other nodeMailer `sendMail` options...
|
|
272
|
+
/** Class representing MongoDB Queue for MailTime */
|
|
273
|
+
class MongoQueue {
|
|
274
|
+
/**
|
|
275
|
+
* Create a MongoQueue instance
|
|
276
|
+
* @param {MongoQueueOption} opts - configuration object
|
|
277
|
+
*/
|
|
278
|
+
constructor (opts) {
|
|
279
|
+
this.name = 'mongo-queue';
|
|
280
|
+
if (!opts || typeof opts !== 'object') {
|
|
281
|
+
throw new TypeError('[mail-time] Configuration object must be passed into MongoQueue constructor');
|
|
282
|
+
}
|
|
244
283
|
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
debug: this.debug,
|
|
249
|
-
prefix: `mailTimeQueue${this.prefix}`,
|
|
250
|
-
resetOnInit: false,
|
|
251
|
-
zombieTime: this.zombieTime,
|
|
252
|
-
minRevolvingDelay: this.minRevolvingDelay,
|
|
253
|
-
maxRevolvingDelay: this.maxRevolvingDelay
|
|
254
|
-
});
|
|
284
|
+
if (!opts.db) {
|
|
285
|
+
throw new Error('[mail-time] [MongoQueue] requires MongoDB database {db} option, like returned from `MongoClient#db()`');
|
|
286
|
+
}
|
|
255
287
|
|
|
256
|
-
|
|
288
|
+
this.db = opts.db;
|
|
289
|
+
if (typeof opts.prefix === 'string') {
|
|
290
|
+
this.__applyPrefix(opts.prefix);
|
|
257
291
|
}
|
|
258
292
|
}
|
|
259
293
|
|
|
260
|
-
|
|
261
|
-
|
|
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);
|
|
262
303
|
}
|
|
263
304
|
|
|
264
|
-
|
|
265
|
-
|
|
305
|
+
/** @internal */
|
|
306
|
+
__ensurePrefix() {
|
|
307
|
+
if (typeof this.prefix !== 'string') {
|
|
308
|
+
this.__applyPrefix(this.mailTimeInstance?.prefix || DEFAULT_PREFIX$2);
|
|
309
|
+
}
|
|
266
310
|
}
|
|
267
311
|
|
|
268
|
-
|
|
269
|
-
|
|
312
|
+
/** @internal */
|
|
313
|
+
__debug(...args) {
|
|
314
|
+
debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
|
|
315
|
+
}
|
|
270
316
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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;
|
|
328
|
+
}
|
|
274
329
|
|
|
275
|
-
|
|
276
|
-
|
|
330
|
+
/**
|
|
331
|
+
* @async
|
|
332
|
+
* @memberOf MongoQueue
|
|
333
|
+
* @name ping
|
|
334
|
+
* @description Check connection to Storage
|
|
335
|
+
* @returns {Promise<object>}
|
|
336
|
+
*/
|
|
337
|
+
async ping() {
|
|
338
|
+
this.__debug('[ping]');
|
|
339
|
+
if (!this.mailTimeInstance) {
|
|
340
|
+
return {
|
|
341
|
+
status: 'Service Unavailable',
|
|
342
|
+
code: 503,
|
|
343
|
+
statusCode: 503,
|
|
344
|
+
error: new Error('MailTime instance not yet assigned to {mailTimeInstance} of Queue Adapter context'),
|
|
345
|
+
};
|
|
277
346
|
}
|
|
278
347
|
|
|
279
|
-
|
|
280
|
-
|
|
348
|
+
try {
|
|
349
|
+
const ping = await this.db.command({ ping: 1 });
|
|
350
|
+
if (ping?.ok === 1) {
|
|
351
|
+
return {
|
|
352
|
+
status: 'OK',
|
|
353
|
+
code: 200,
|
|
354
|
+
statusCode: 200,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
} catch (pingError) {
|
|
358
|
+
return {
|
|
359
|
+
status: 'Internal Server Error',
|
|
360
|
+
code: 500,
|
|
361
|
+
statusCode: 500,
|
|
362
|
+
error: pingError,
|
|
363
|
+
};
|
|
281
364
|
}
|
|
282
365
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
366
|
+
return {
|
|
367
|
+
status: 'Service Unavailable',
|
|
368
|
+
code: 503,
|
|
369
|
+
statusCode: 503,
|
|
370
|
+
error: new Error('Service Unavailable'),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
288
373
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
374
|
+
/**
|
|
375
|
+
* @memberOf MongoQueue
|
|
376
|
+
* @name iterate
|
|
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>}
|
|
380
|
+
*/
|
|
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
|
+
|
|
392
|
+
try {
|
|
393
|
+
const cursor = this.collection.find({
|
|
394
|
+
isSent: false,
|
|
395
|
+
isFailed: false,
|
|
396
|
+
isCancelled: false,
|
|
397
|
+
sendAt: {
|
|
398
|
+
$lte: now,
|
|
399
|
+
},
|
|
400
|
+
tries: {
|
|
401
|
+
$lt: this.mailTimeInstance.maxTries,
|
|
402
|
+
},
|
|
403
|
+
$or: [
|
|
404
|
+
{ isSending: { $ne: true } },
|
|
405
|
+
{ sendingAt: { $lte: now - sendingTimeout } },
|
|
406
|
+
],
|
|
407
|
+
}, {
|
|
408
|
+
projection: {
|
|
409
|
+
_id: 1,
|
|
410
|
+
uuid: 1,
|
|
411
|
+
tries: 1,
|
|
412
|
+
template: 1,
|
|
413
|
+
transport: 1,
|
|
414
|
+
isSent: 1,
|
|
415
|
+
isFailed: 1,
|
|
416
|
+
isCancelled: 1,
|
|
417
|
+
isSending: 1,
|
|
418
|
+
sendingAt: 1,
|
|
419
|
+
mailOptions: 1,
|
|
420
|
+
concatSubject: 1,
|
|
421
|
+
},
|
|
422
|
+
});
|
|
298
423
|
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
_mailOpts.text += '\r\n' + this.___render(task.mailOptions[i].text, task.mailOptions[i]);
|
|
302
|
-
} else {
|
|
303
|
-
_mailOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]);
|
|
304
|
-
}
|
|
305
|
-
delete task.mailOptions[i].text;
|
|
424
|
+
if (limit > 0) {
|
|
425
|
+
cursor.limit(limit);
|
|
306
426
|
}
|
|
307
427
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (task.mailOptions.length > 1) {
|
|
316
|
-
_mailOpts.subject = task.concatSubject || this.concatSubject || _mailOpts.subject;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (!_mailOpts.from && this.from) {
|
|
320
|
-
_mailOpts.from = this.from(transport);
|
|
428
|
+
while (await cursor.hasNext()) {
|
|
429
|
+
await this.mailTimeInstance.___dispatch(await cursor.next());
|
|
430
|
+
}
|
|
431
|
+
await cursor.close();
|
|
432
|
+
} catch (iterateError) {
|
|
433
|
+
logError('[iterate] [while/await] [iterateError]', iterateError);
|
|
321
434
|
}
|
|
322
|
-
|
|
323
|
-
return _mailOpts;
|
|
324
435
|
}
|
|
325
436
|
|
|
326
437
|
/**
|
|
327
|
-
* @
|
|
328
|
-
* @
|
|
329
|
-
* @
|
|
330
|
-
* @
|
|
438
|
+
* @async
|
|
439
|
+
* @memberOf MongoQueue
|
|
440
|
+
* @name getPendingTo
|
|
441
|
+
* @description get queued task by `to` field (addressee)
|
|
442
|
+
* @param to {string} - email address
|
|
443
|
+
* @param sendAt {number} - timestamp
|
|
444
|
+
* @returns {Promise<object|null>}
|
|
331
445
|
*/
|
|
332
|
-
|
|
333
|
-
|
|
446
|
+
async getPendingTo(to, sendAt) {
|
|
447
|
+
this.__debug('[getPendingTo]', to, sendAt);
|
|
448
|
+
if (typeof to !== 'string' || typeof sendAt !== 'number') {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
this.__ensurePrefix();
|
|
452
|
+
|
|
453
|
+
return await this.collection.findOne({
|
|
454
|
+
to,
|
|
334
455
|
isSent: false,
|
|
456
|
+
isFailed: false,
|
|
457
|
+
isCancelled: false,
|
|
458
|
+
isSending: { $ne: true },
|
|
459
|
+
tries: { $lt: this.mailTimeInstance.maxTries },
|
|
335
460
|
sendAt: {
|
|
336
|
-
$lte:
|
|
461
|
+
$lte: sendAt,
|
|
337
462
|
},
|
|
338
|
-
tries: {
|
|
339
|
-
$lt: this.maxTries
|
|
340
|
-
}
|
|
341
463
|
}, {
|
|
342
464
|
projection: {
|
|
343
465
|
_id: 1,
|
|
466
|
+
to: 1,
|
|
467
|
+
uuid: 1,
|
|
344
468
|
tries: 1,
|
|
345
|
-
|
|
346
|
-
|
|
469
|
+
isSent: 1,
|
|
470
|
+
isFailed: 1,
|
|
471
|
+
isCancelled: 1,
|
|
347
472
|
mailOptions: 1,
|
|
348
|
-
|
|
349
|
-
}
|
|
473
|
+
},
|
|
350
474
|
});
|
|
475
|
+
}
|
|
351
476
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
let transportIndex;
|
|
367
|
-
if (this.strategy === 'balancer') {
|
|
368
|
-
this.transport = this.transport + 1;
|
|
369
|
-
if (this.transport >= this.transports.length) {
|
|
370
|
-
this.transport = 0;
|
|
371
|
-
}
|
|
372
|
-
transportIndex = this.transport;
|
|
373
|
-
transport = this.transports[transportIndex];
|
|
374
|
-
} else {
|
|
375
|
-
transportIndex = task.transport;
|
|
376
|
-
transport = this.transports[transportIndex];
|
|
377
|
-
}
|
|
477
|
+
/**
|
|
478
|
+
* @async
|
|
479
|
+
* @memberOf MongoQueue
|
|
480
|
+
* @name push
|
|
481
|
+
* @description push task to the queue/storage
|
|
482
|
+
* @param task {object} - task's object
|
|
483
|
+
* @returns {Promise<void 0>}
|
|
484
|
+
*/
|
|
485
|
+
async push(task) {
|
|
486
|
+
this.__debug('[push]', task?.uuid);
|
|
487
|
+
if (!task || typeof task !== 'object') {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
this.__ensurePrefix();
|
|
378
491
|
|
|
379
|
-
|
|
492
|
+
if (task.sendAt instanceof Date) {
|
|
493
|
+
task.sendAt = +task.sendAt;
|
|
494
|
+
}
|
|
495
|
+
await this.collection.insertOne(task);
|
|
496
|
+
}
|
|
380
497
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
498
|
+
/**
|
|
499
|
+
* @async
|
|
500
|
+
* @memberOf MongoQueue
|
|
501
|
+
* @name cancel
|
|
502
|
+
* @description cancel scheduled email
|
|
503
|
+
* @param uuid {string} - email's uuid
|
|
504
|
+
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
|
|
505
|
+
*/
|
|
506
|
+
async cancel(uuid) {
|
|
507
|
+
this.__debug('[cancel]', uuid);
|
|
508
|
+
if (typeof uuid !== 'string') {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
this.__ensurePrefix();
|
|
387
512
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
513
|
+
const task = await this.collection.findOne({ uuid }, {
|
|
514
|
+
projection: {
|
|
515
|
+
_id: 1,
|
|
516
|
+
uuid: 1,
|
|
517
|
+
isSent: 1,
|
|
518
|
+
isCancelled: 1,
|
|
519
|
+
},
|
|
520
|
+
});
|
|
392
521
|
|
|
393
|
-
|
|
522
|
+
if (!task || task.isSent === true || task.isCancelled === true) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
394
525
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}).catch(mongoErrorHandler);
|
|
399
|
-
}
|
|
526
|
+
if (!this.mailTimeInstance.keepHistory) {
|
|
527
|
+
return await this.remove(task);
|
|
528
|
+
}
|
|
400
529
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
});
|
|
404
|
-
} catch (e) {
|
|
405
|
-
_logError('Exception during runtime:', e);
|
|
406
|
-
this.___handleError(task, e, {});
|
|
407
|
-
}
|
|
408
|
-
}).catch((forEachError) => {
|
|
409
|
-
_logError('[___send] [forEach] [forEachError]', forEachError);
|
|
410
|
-
}).finally(() => {
|
|
411
|
-
ready();
|
|
412
|
-
cursor.close();
|
|
530
|
+
return await this.update(task, {
|
|
531
|
+
isCancelled: true,
|
|
413
532
|
});
|
|
414
533
|
}
|
|
415
534
|
|
|
416
535
|
/**
|
|
417
|
-
* @
|
|
418
|
-
* @
|
|
419
|
-
* @
|
|
420
|
-
* @
|
|
536
|
+
* @async
|
|
537
|
+
* @memberOf MongoQueue
|
|
538
|
+
* @name remove
|
|
539
|
+
* @description remove task from queue
|
|
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)
|
|
542
|
+
* @returns {Promise<boolean>} returns `true` if removed or `false` if not found
|
|
421
543
|
*/
|
|
422
|
-
|
|
423
|
-
this.
|
|
544
|
+
async remove(task, opts) {
|
|
545
|
+
this.__debug('[remove]', task?.uuid);
|
|
546
|
+
if (!task || typeof task !== 'object') {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
this.__ensurePrefix();
|
|
550
|
+
|
|
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;
|
|
424
562
|
}
|
|
425
563
|
|
|
426
564
|
/**
|
|
427
|
-
* @
|
|
428
|
-
* @
|
|
429
|
-
* @
|
|
430
|
-
* @
|
|
431
|
-
* @param
|
|
432
|
-
* @param
|
|
433
|
-
* @
|
|
434
|
-
* @returns {void}
|
|
565
|
+
* @async
|
|
566
|
+
* @memberOf MongoQueue
|
|
567
|
+
* @name update
|
|
568
|
+
* @description update task in queue
|
|
569
|
+
* @param task {object} - task's object
|
|
570
|
+
* @param updateObj {object} - fields with new values to update
|
|
571
|
+
* @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
|
|
435
572
|
*/
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if (!
|
|
439
|
-
|
|
573
|
+
async update(task, updateObj) {
|
|
574
|
+
this.__debug('[update]', task?.uuid);
|
|
575
|
+
if (!task || typeof task !== 'object' || !updateObj || typeof updateObj !== 'object') {
|
|
576
|
+
return false;
|
|
440
577
|
}
|
|
578
|
+
this.__ensurePrefix();
|
|
441
579
|
|
|
442
|
-
if (
|
|
443
|
-
|
|
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;
|
|
444
593
|
}
|
|
445
594
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
595
|
+
const query = {
|
|
596
|
+
_id: task._id,
|
|
597
|
+
};
|
|
449
598
|
|
|
450
|
-
if (
|
|
451
|
-
|
|
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;
|
|
452
616
|
}
|
|
453
617
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
delete opts.concatSubject;
|
|
460
|
-
|
|
461
|
-
if (typeof opts.to !== 'string' && (!(opts.to instanceof Array) || !opts.to.length)) {
|
|
462
|
-
throw new Error('[mail-time] `mailOptions.to` is required and must be a string or non-empty Array');
|
|
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;
|
|
463
623
|
}
|
|
624
|
+
return (res?.modifiedCount || 0) >= 1;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
464
627
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
}).then((task) => {
|
|
479
|
-
if (task) {
|
|
480
|
-
const queue = task.mailOptions || [];
|
|
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
|
+
*/
|
|
481
640
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
641
|
+
/**
|
|
642
|
+
* @typedef {object} RedisQueueOption
|
|
643
|
+
* @property {RedisClient} client
|
|
644
|
+
* @property {string} [prefix]
|
|
645
|
+
*/
|
|
487
646
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
_id: task._id
|
|
491
|
-
}, {
|
|
492
|
-
$set: {
|
|
493
|
-
mailOptions: queue
|
|
494
|
-
}
|
|
495
|
-
}).then(() => {
|
|
496
|
-
const _id = task._id.toHexString();
|
|
497
|
-
if (!this.callbacks[_id]) {
|
|
498
|
-
this.callbacks[_id] = [];
|
|
499
|
-
}
|
|
500
|
-
this.callbacks[_id].push(callback);
|
|
501
|
-
}).catch(mongoErrorHandler);
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
647
|
+
const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
|
|
648
|
+
const DEFAULT_PREFIX$1 = 'default';
|
|
504
649
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
+
};
|
|
512
658
|
|
|
513
|
-
|
|
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;
|
|
514
673
|
}
|
|
515
|
-
|
|
516
|
-
this.___addToQueue({
|
|
517
|
-
sendAt: _sendAt,
|
|
518
|
-
template: _template,
|
|
519
|
-
mailOptions: opts,
|
|
520
|
-
concatSubject: _concatSubject
|
|
521
|
-
}, callback);
|
|
522
|
-
|
|
523
|
-
return;
|
|
524
674
|
}
|
|
675
|
+
return true;
|
|
676
|
+
};
|
|
525
677
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
+
|
|
706
|
+
/** Class representing Redis Queue for MailTime */
|
|
707
|
+
class RedisQueue {
|
|
708
|
+
/**
|
|
709
|
+
* Create a RedisQueue instance
|
|
710
|
+
* @param {RedisQueueOption} opts - configuration object
|
|
711
|
+
*/
|
|
712
|
+
constructor (opts) {
|
|
713
|
+
this.name = 'redis-queue';
|
|
714
|
+
if (!opts || typeof opts !== 'object') {
|
|
715
|
+
throw new TypeError('[mail-time] Configuration object must be passed into RedisQueue constructor');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (!opts.client) {
|
|
719
|
+
throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
|
|
720
|
+
}
|
|
721
|
+
|
|
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
|
+
}
|
|
745
|
+
|
|
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;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* @async
|
|
761
|
+
* @memberOf RedisQueue
|
|
762
|
+
* @name ping
|
|
763
|
+
* @description Check connection to Storage
|
|
764
|
+
* @returns {Promise<object>}
|
|
765
|
+
*/
|
|
766
|
+
async ping() {
|
|
767
|
+
this.__debug('[ping]');
|
|
768
|
+
if (!this.mailTimeInstance) {
|
|
769
|
+
return {
|
|
770
|
+
status: 'Service Unavailable',
|
|
771
|
+
code: 503,
|
|
772
|
+
statusCode: 503,
|
|
773
|
+
error: new Error('MailTime instance not yet assigned to {mailTimeInstance} of Queue Adapter context'),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
try {
|
|
778
|
+
const ping = await this.client.ping();
|
|
779
|
+
if (ping === 'PONG') {
|
|
780
|
+
return {
|
|
781
|
+
status: 'OK',
|
|
782
|
+
code: 200,
|
|
783
|
+
statusCode: 200,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
} catch (pingError) {
|
|
787
|
+
return {
|
|
788
|
+
status: 'Internal Server Error',
|
|
789
|
+
code: 500,
|
|
790
|
+
statusCode: 500,
|
|
791
|
+
error: pingError,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
status: 'Service Unavailable',
|
|
797
|
+
code: 503,
|
|
798
|
+
statusCode: 503,
|
|
799
|
+
error: new Error('Service Unavailable'),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* @memberOf RedisQueue
|
|
805
|
+
* @name iterate
|
|
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>}
|
|
809
|
+
*/
|
|
810
|
+
async iterate(opts) {
|
|
811
|
+
this.__debug('[iterate]', opts);
|
|
812
|
+
try {
|
|
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');
|
|
826
|
+
const cursor = this.client.scanIterator({
|
|
827
|
+
TYPE: 'string',
|
|
828
|
+
MATCH: matchPattern,
|
|
829
|
+
COUNT: 9999,
|
|
830
|
+
});
|
|
831
|
+
|
|
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
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
} catch (iterateError) {
|
|
860
|
+
logError('[iterate] [for/await] [iterateError]', iterateError);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* @async
|
|
866
|
+
* @memberOf RedisQueue
|
|
867
|
+
* @name getPendingTo
|
|
868
|
+
* @description get queued task by `to` field (addressee)
|
|
869
|
+
* @param to {string} - email address
|
|
870
|
+
* @param sendAt {number} - timestamp
|
|
871
|
+
* @returns {Promise<object|null>}
|
|
872
|
+
*/
|
|
873
|
+
async getPendingTo(to, sendAt) {
|
|
874
|
+
this.__debug('[getPendingTo]', to, sendAt);
|
|
875
|
+
if (typeof to !== 'string' || typeof sendAt !== 'number') {
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const concatKey = this.__getKey(to, 'concatletter');
|
|
880
|
+
const uuid = await this.client.get(concatKey);
|
|
881
|
+
if (!uuid) {
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const letterKey = this.__getKey(uuid, 'letter');
|
|
886
|
+
const taskJSON = await this.client.get(letterKey);
|
|
887
|
+
if (!taskJSON) {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
|
|
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) {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return task;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* @async
|
|
901
|
+
* @memberOf RedisQueue
|
|
902
|
+
* @name push
|
|
903
|
+
* @description push task to the queue/storage
|
|
904
|
+
* @param task {object} - task's object
|
|
905
|
+
* @returns {Promise<void 0>}
|
|
906
|
+
*/
|
|
907
|
+
async push(task) {
|
|
908
|
+
this.__debug('[push]', task?.uuid);
|
|
909
|
+
if (!task || typeof task !== 'object') {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (task.sendAt instanceof Date) {
|
|
914
|
+
task.sendAt = +task.sendAt;
|
|
915
|
+
}
|
|
916
|
+
|
|
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}`);
|
|
936
|
+
if (task.to) {
|
|
937
|
+
await this.client.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
|
|
938
|
+
PXAT: task.sendAt - 128,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* @async
|
|
945
|
+
* @memberOf RedisQueue
|
|
946
|
+
* @name cancel
|
|
947
|
+
* @description cancel scheduled email
|
|
948
|
+
* @param uuid {string} - email's uuid
|
|
949
|
+
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
|
|
950
|
+
*/
|
|
951
|
+
async cancel(uuid) {
|
|
952
|
+
this.__debug('[cancel]', uuid);
|
|
953
|
+
if (typeof uuid !== 'string') {
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
await this.client.del(this.__getKey(uuid, 'sendat'));
|
|
958
|
+
const letterKey = this.__getKey(uuid, 'letter');
|
|
959
|
+
const taskJSON = await this.client.get(letterKey);
|
|
960
|
+
if (!taskJSON) {
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const task = JSON.parse(taskJSON);
|
|
965
|
+
if (!task || task.isSent === true || task.isCancelled === true) {
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (!this.mailTimeInstance.keepHistory) {
|
|
970
|
+
return await this.remove(task);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return await this.update(task, {
|
|
974
|
+
isCancelled: true,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* @async
|
|
980
|
+
* @memberOf RedisQueue
|
|
981
|
+
* @name remove
|
|
982
|
+
* @description remove task from queue
|
|
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)
|
|
985
|
+
* @returns {Promise<boolean>} returns `true` if removed or `false` if not found
|
|
986
|
+
*/
|
|
987
|
+
async remove(task, opts) {
|
|
988
|
+
this.__debug('[remove]', task?.uuid);
|
|
989
|
+
if (!task || typeof task !== 'object' || typeof task.uuid !== 'string') {
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
|
|
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
|
+
|
|
1030
|
+
const exists = await this.client.exists(letterKey);
|
|
1031
|
+
if (!exists) {
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
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);
|
|
1040
|
+
return true;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* @async
|
|
1045
|
+
* @memberOf RedisQueue
|
|
1046
|
+
* @name update
|
|
1047
|
+
* @description update task in queue
|
|
1048
|
+
* @param task {object} - task's object
|
|
1049
|
+
* @param updateObj {object} - fields with new values to update
|
|
1050
|
+
* @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
|
|
1051
|
+
*/
|
|
1052
|
+
async update(task, updateObj) {
|
|
1053
|
+
this.__debug('[update]', task?.uuid);
|
|
1054
|
+
if (!task || typeof task !== 'object' || typeof task.uuid !== 'string' || !updateObj || typeof updateObj !== 'object') {
|
|
1055
|
+
return false;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
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;
|
|
1065
|
+
|
|
1066
|
+
try {
|
|
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) {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const currentTask = JSON.parse(taskJSON);
|
|
1149
|
+
const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
|
|
1150
|
+
await this.client.set(letterKey, JSON.stringify(updatedTask));
|
|
1151
|
+
|
|
1152
|
+
if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
|
|
1153
|
+
await this.client.del(sendatKey);
|
|
1154
|
+
} else if (updatedTask.sendAt) {
|
|
1155
|
+
await this.client.set(sendatKey, `${+updatedTask.sendAt}`);
|
|
1156
|
+
}
|
|
1157
|
+
return true;
|
|
1158
|
+
} catch (opError) {
|
|
1159
|
+
logError('[update] [try/catch] [opError]', opError);
|
|
1160
|
+
return false;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* @internal
|
|
1166
|
+
* @memberOf RedisQueue
|
|
1167
|
+
* @name __getKey
|
|
1168
|
+
* @description helper to generate scoped key
|
|
1169
|
+
* @param uuid {string} - letter's uuid (or "to" address for `concatletter` keys)
|
|
1170
|
+
* @param type {string} - "letter" or "sendat" or "concatletter"
|
|
1171
|
+
* @returns {string} returns key used by Redis
|
|
1172
|
+
*/
|
|
1173
|
+
__getKey(uuid, type = 'letter') {
|
|
1174
|
+
if (!KEY_TYPES.has(type)) {
|
|
1175
|
+
throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
|
|
1176
|
+
}
|
|
1177
|
+
this.__ensurePrefix();
|
|
1178
|
+
return `${this.uniqueName}:${type}:${uuid}`;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* @typedef {object} PostgresQueryResult
|
|
1184
|
+
* @property {number | null | undefined} [rowCount]
|
|
1185
|
+
* @property {unknown[]} [rows]
|
|
1186
|
+
*/
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* @typedef {object} PostgresClient
|
|
1190
|
+
* @property {(queryText: string, values?: unknown[]) => Promise<PostgresQueryResult>} query
|
|
1191
|
+
*/
|
|
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);
|
|
1220
|
+
}
|
|
1221
|
+
return mailOptions;
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
const normalizeRow = (row) => {
|
|
1225
|
+
if (!row) {
|
|
1226
|
+
return null;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
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
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/** @internal */
|
|
1270
|
+
__applyPrefix(prefix) {
|
|
1271
|
+
this.prefix = prefix;
|
|
1272
|
+
this.__readyPromise = this.__setup();
|
|
1273
|
+
this.__readyPromise.catch(() => void 0);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** @internal */
|
|
1277
|
+
__ensurePrefix() {
|
|
1278
|
+
if (typeof this.prefix !== 'string') {
|
|
1279
|
+
this.__applyPrefix(this.mailTimeInstance?.prefix || DEFAULT_PREFIX);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
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]);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
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
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
} catch (pingError) {
|
|
1367
|
+
return {
|
|
1368
|
+
status: 'Internal Server Error',
|
|
1369
|
+
code: 500,
|
|
1370
|
+
statusCode: 500,
|
|
1371
|
+
error: pingError,
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
|
|
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));
|
|
1421
|
+
}
|
|
1422
|
+
} catch (iterateError) {
|
|
1423
|
+
logError('[PostgresQueue] [iterate] [iterateError]', iterateError);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
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();
|
|
1476
|
+
|
|
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') {
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
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;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
let value = persistObj[key];
|
|
1630
|
+
if (key === 'sendAt' && value instanceof Date) {
|
|
1631
|
+
value = +value;
|
|
1632
|
+
}
|
|
1633
|
+
if (key === 'mailOptions') {
|
|
1634
|
+
value = JSON.stringify(value);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
values.push(value);
|
|
1638
|
+
sets.push(`${fieldMap[key]} = $${values.length}`);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
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;
|
|
1688
|
+
}
|
|
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;
|
|
2000
|
+
};
|
|
2001
|
+
|
|
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>';
|
|
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
|
+
|
|
2064
|
+
/** Class of MailTime */
|
|
2065
|
+
class MailTime {
|
|
2066
|
+
/**
|
|
2067
|
+
* Create a MailTime instance
|
|
2068
|
+
* @param {MailTimeOptions} opts - configuration object
|
|
2069
|
+
*/
|
|
2070
|
+
constructor (opts) {
|
|
2071
|
+
if (!opts || typeof opts !== 'object') {
|
|
2072
|
+
throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor');
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
if (!opts.queue || typeof opts.queue !== 'object') {
|
|
2076
|
+
throw new Error('[mail-time] {queue} option is required: provide a MongoQueue, RedisQueue, PostgresQueue, or CustomQueue instance');
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
this.queue = opts.queue;
|
|
2080
|
+
|
|
2081
|
+
for (let i = queueMethods.length - 1; i >= 0; i--) {
|
|
2082
|
+
if (typeof this.queue[queueMethods[i]] !== 'function') {
|
|
2083
|
+
throw new Error(`[mail-time] {queue} instance is missing {${queueMethods[i]}} method that is required!`);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
this.debug = opts.debug === true;
|
|
2088
|
+
this.__debug = (...args) => {
|
|
2089
|
+
debug(this.debug, `[${this.prefix || 'default'}]`, ...args);
|
|
2090
|
+
};
|
|
2091
|
+
|
|
2092
|
+
this.type = (opts.type === 'client' || opts.type === 'server') ? opts.type : 'server';
|
|
2093
|
+
this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '';
|
|
2094
|
+
|
|
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
|
+
}
|
|
2099
|
+
this.maxTries = opts.retries + 1;
|
|
2100
|
+
} else if (typeof opts.maxTries === 'number') {
|
|
2101
|
+
this.maxTries = (opts.maxTries < 1) ? 1 : opts.maxTries;
|
|
2102
|
+
} else {
|
|
2103
|
+
this.maxTries = 60;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
if (typeof opts.retryDelay === 'number') {
|
|
2107
|
+
this.retryDelay = opts.retryDelay;
|
|
2108
|
+
} else if (typeof opts.interval === 'number') {
|
|
2109
|
+
this.retryDelay = opts.interval * 1000;
|
|
2110
|
+
} else {
|
|
2111
|
+
this.retryDelay = 60000;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
|
|
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;
|
|
2130
|
+
this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup';
|
|
2131
|
+
this.transports = Array.isArray(opts.transports) ? opts.transports : [];
|
|
2132
|
+
this.transport = 0;
|
|
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
|
+
}
|
|
2144
|
+
|
|
2145
|
+
this.queue.mailTimeInstance = this;
|
|
2146
|
+
|
|
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>';
|
|
2160
|
+
|
|
2161
|
+
if (typeof opts.concatDelay === 'number') {
|
|
2162
|
+
this.concatDelay = opts.concatDelay;
|
|
2163
|
+
} else if (typeof opts.concatThrottling === 'number') {
|
|
2164
|
+
this.concatDelay = opts.concatThrottling * 1000;
|
|
2165
|
+
} else {
|
|
2166
|
+
this.concatDelay = 60000;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
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}]`);
|
|
2179
|
+
|
|
2180
|
+
/** SERVER-SPECIFIC CHECKS AND CONFIG */
|
|
2181
|
+
if (this.type === 'server') {
|
|
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`');
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
if (!opts.josk || typeof opts.josk !== 'object') {
|
|
2187
|
+
throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}');
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
if (!opts.josk.adapter || typeof opts.josk.adapter !== 'object') {
|
|
2191
|
+
throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class');
|
|
2192
|
+
}
|
|
2193
|
+
|
|
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
|
+
}
|
|
2204
|
+
|
|
2205
|
+
return adapterOptions;
|
|
2206
|
+
};
|
|
2207
|
+
|
|
2208
|
+
const adapterType = opts.josk.adapter.type;
|
|
2209
|
+
if (adapterType === 'mongo') {
|
|
2210
|
+
if (!opts.josk.adapter.db) {
|
|
2211
|
+
throw new Error('[mail-time] {josk.adapter.db} option required for {josk.adapter.type: "mongo"}');
|
|
2212
|
+
}
|
|
2213
|
+
this.josk.adapter = new josk.MongoAdapter(buildAdapterOptions());
|
|
2214
|
+
} else if (adapterType === 'redis') {
|
|
2215
|
+
if (!opts.josk.adapter.client) {
|
|
2216
|
+
throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "redis"}');
|
|
2217
|
+
}
|
|
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());
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
this.josk.minRevolvingDelay = (typeof opts.josk.minRevolvingDelay === 'number') ? opts.josk.minRevolvingDelay : 512;
|
|
2227
|
+
this.josk.maxRevolvingDelay = (typeof opts.josk.maxRevolvingDelay === 'number') ? opts.josk.maxRevolvingDelay : 2048;
|
|
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
|
+
}
|
|
2242
|
+
|
|
2243
|
+
this.scheduler = new josk.JoSk({
|
|
2244
|
+
debug: this.debug,
|
|
2245
|
+
...this.josk,
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
this.__schedulerTimer = this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
this.__readyPromise = this.___ready();
|
|
2252
|
+
this.__readyPromise.catch(() => void 0);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
static get Template() {
|
|
2256
|
+
return DEFAULT_TEMPLATE;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
static set Template(newVal) {
|
|
2260
|
+
DEFAULT_TEMPLATE = newVal;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/**
|
|
2264
|
+
* @async
|
|
2265
|
+
* @memberOf MailTime
|
|
2266
|
+
* @name ping
|
|
2267
|
+
* @description Check package readiness and connection to Storage
|
|
2268
|
+
* @returns {Promise<MailTimePingResult>}
|
|
2269
|
+
* @throws {Error}
|
|
2270
|
+
*/
|
|
2271
|
+
async ping() {
|
|
2272
|
+
this.__debug('[ping]');
|
|
2273
|
+
if (this.scheduler) {
|
|
2274
|
+
const schedulerPing = await this.scheduler.ping();
|
|
2275
|
+
if (schedulerPing.status !== 'OK') {
|
|
2276
|
+
return schedulerPing;
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
return await this.queue.ping();
|
|
2280
|
+
}
|
|
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
|
+
|
|
2331
|
+
/**
|
|
2332
|
+
* @memberOf MailTime
|
|
2333
|
+
* @name send
|
|
2334
|
+
* @description alias of `sendMail`
|
|
2335
|
+
* @param {MailTimeMailOptions} opts - email options
|
|
2336
|
+
* @returns {Promise<string>} uuid of the email
|
|
2337
|
+
*/
|
|
2338
|
+
async send(opts) {
|
|
2339
|
+
this.__debug('[send]', opts);
|
|
2340
|
+
return await this.sendMail(opts);
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/**
|
|
2344
|
+
* @async
|
|
2345
|
+
* @memberOf MailTime
|
|
2346
|
+
* @name sendMail
|
|
2347
|
+
* @description add email to the queue or append to existing letter if {concatEmails: true}
|
|
2348
|
+
* @param {MailTimeMailOptions} opts - email options
|
|
2349
|
+
* @returns {Promise<string>} uuid of the email
|
|
2350
|
+
* @throws {Error}
|
|
2351
|
+
*/
|
|
2352
|
+
async sendMail(opts = {}) {
|
|
2353
|
+
this.__debug('[sendMail]', opts);
|
|
2354
|
+
if (!opts.html && !opts.text) {
|
|
2355
|
+
throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
let sendAt = opts.sendAt;
|
|
2359
|
+
if (sendAt instanceof Date) {
|
|
2360
|
+
sendAt = +sendAt;
|
|
2361
|
+
}
|
|
2362
|
+
if (typeof sendAt !== 'number' || !Number.isFinite(sendAt)) {
|
|
2363
|
+
sendAt = Date.now();
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
const template = (typeof opts.template === 'string') ? opts.template : false;
|
|
2367
|
+
const concatSubject = (typeof opts.concatSubject === 'string') ? opts.concatSubject : false;
|
|
2368
|
+
|
|
2369
|
+
const mailOptions = { ...opts };
|
|
2370
|
+
delete mailOptions.sendAt;
|
|
2371
|
+
delete mailOptions.template;
|
|
2372
|
+
delete mailOptions.concatSubject;
|
|
2373
|
+
|
|
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');
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
if (this.concatEmails) {
|
|
2379
|
+
sendAt = sendAt + this.concatDelay;
|
|
2380
|
+
const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
|
|
2381
|
+
|
|
2382
|
+
if (task) {
|
|
2383
|
+
const pendingMailOptions = task.mailOptions || [];
|
|
2384
|
+
|
|
2385
|
+
for (let i = 0; i < pendingMailOptions.length; i++) {
|
|
2386
|
+
if (equals(pendingMailOptions[i], mailOptions)) {
|
|
2387
|
+
return task.uuid;
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
const appended = await this.queue.update(task, {
|
|
2392
|
+
appendMailOption: mailOptions,
|
|
2393
|
+
});
|
|
2394
|
+
if (appended) {
|
|
2395
|
+
return task.uuid;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
return await this.___addToQueue({
|
|
2401
|
+
sendAt,
|
|
2402
|
+
template,
|
|
2403
|
+
concatSubject,
|
|
2404
|
+
mailOptions,
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
/**
|
|
2409
|
+
* @async
|
|
2410
|
+
* @memberOf MailTime
|
|
2411
|
+
* @name cancel
|
|
2412
|
+
* @description alias of `cancelMail`
|
|
2413
|
+
* @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
|
|
2414
|
+
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
|
|
2415
|
+
*/
|
|
2416
|
+
async cancel(uuid) {
|
|
2417
|
+
this.__debug('[cancel]', uuid);
|
|
2418
|
+
return await this.cancelMail(uuid);
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
/**
|
|
2422
|
+
* @async
|
|
2423
|
+
* @memberOf MailTime
|
|
2424
|
+
* @name cancelMail
|
|
2425
|
+
* @description remove email from the queue or mark as `isCancelled`
|
|
2426
|
+
* @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
|
|
2427
|
+
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
|
|
2428
|
+
*/
|
|
2429
|
+
async cancelMail(uuid) {
|
|
2430
|
+
this.__debug('[cancelMail]', uuid);
|
|
2431
|
+
const resolved = (uuid && typeof uuid.then === 'function') ? await uuid : uuid;
|
|
2432
|
+
return await this.queue.cancel(resolved);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
/**
|
|
2436
|
+
* @async
|
|
2437
|
+
* @internal
|
|
2438
|
+
* @memberOf MailTime
|
|
2439
|
+
* @name ___handleError
|
|
2440
|
+
* @description Handle runtime errors and pass to `onError` callback
|
|
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
|
|
2444
|
+
* @returns {Promise<void 0>}
|
|
2445
|
+
*/
|
|
2446
|
+
async ___handleError(task, error, info) {
|
|
2447
|
+
this.__debug('[private handleError]', { task, error, info });
|
|
535
2448
|
if (!task) {
|
|
536
2449
|
return;
|
|
537
2450
|
}
|
|
538
2451
|
|
|
539
2452
|
if (task.tries >= this.maxTries) {
|
|
2453
|
+
this.___finalizeRejected(task, info);
|
|
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;
|
|
540
2465
|
if (!this.keepHistory) {
|
|
541
|
-
this.
|
|
542
|
-
|
|
543
|
-
|
|
2466
|
+
finalized = await this.queue.remove(task, leaseRemove);
|
|
2467
|
+
} else {
|
|
2468
|
+
finalized = await this.queue.update(task, leaseUpdate);
|
|
544
2469
|
}
|
|
545
2470
|
|
|
546
|
-
|
|
547
|
-
|
|
2471
|
+
if (!finalized) {
|
|
2472
|
+
this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
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);
|
|
2482
|
+
this.onError(error, task, info);
|
|
548
2483
|
return;
|
|
549
2484
|
}
|
|
550
2485
|
|
|
551
2486
|
let transportIndex = task.transport;
|
|
552
2487
|
|
|
553
2488
|
if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) {
|
|
554
|
-
|
|
555
|
-
if (transportIndex > this.transports.length - 1) {
|
|
556
|
-
transportIndex = 0;
|
|
557
|
-
}
|
|
2489
|
+
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
558
2490
|
}
|
|
559
2491
|
|
|
560
|
-
this.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
2492
|
+
const released = await this.queue.update(task, __withLease(task, {
|
|
2493
|
+
isSending: false,
|
|
2494
|
+
sendingAt: 0,
|
|
2495
|
+
sendAt: Date.now() + this.retryDelay,
|
|
2496
|
+
transport: transportIndex,
|
|
2497
|
+
}));
|
|
2498
|
+
|
|
2499
|
+
if (!released) {
|
|
2500
|
+
this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
569
2503
|
|
|
570
|
-
|
|
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);
|
|
571
2505
|
}
|
|
572
2506
|
|
|
573
2507
|
/**
|
|
2508
|
+
* @async
|
|
2509
|
+
* @internal
|
|
574
2510
|
* @memberOf MailTime
|
|
575
2511
|
* @name ___addToQueue
|
|
576
|
-
* @
|
|
577
|
-
* @param
|
|
578
|
-
* @
|
|
579
|
-
* @param opts.mailOptions {Object} - MailOptions according to NodeMailer lib
|
|
580
|
-
* @param opts.concatSubject {String} - Email subject used when sending multiple concatenated emails
|
|
581
|
-
* @param callback {Function} - [OPTIONAL] Callback function
|
|
582
|
-
* @returns {void}
|
|
2512
|
+
* @description Prepare task's object and push to the queue
|
|
2513
|
+
* @param {{ sendAt: number, template: string | false, mailOptions: MailTimeMailOptions, concatSubject: string | false }} opts - Email options
|
|
2514
|
+
* @returns {Promise<string>} message uuid
|
|
583
2515
|
*/
|
|
584
|
-
___addToQueue(opts
|
|
585
|
-
|
|
2516
|
+
async ___addToQueue(opts) {
|
|
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
|
+
}
|
|
586
2523
|
const task = {
|
|
2524
|
+
uuid: node_crypto.randomUUID(),
|
|
587
2525
|
tries: 0,
|
|
588
2526
|
isSent: false,
|
|
589
2527
|
sendAt: opts.sendAt,
|
|
2528
|
+
isFailed: false,
|
|
2529
|
+
isSending: false,
|
|
2530
|
+
sendingAt: 0,
|
|
590
2531
|
template: opts.template,
|
|
591
|
-
transport:
|
|
2532
|
+
transport: transportIndex,
|
|
2533
|
+
isCancelled: false,
|
|
592
2534
|
mailOptions: [opts.mailOptions],
|
|
593
|
-
concatSubject: opts.concatSubject
|
|
2535
|
+
concatSubject: opts.concatSubject,
|
|
594
2536
|
};
|
|
595
2537
|
|
|
596
2538
|
if (this.concatEmails) {
|
|
597
2539
|
task.to = opts.mailOptions.to;
|
|
598
2540
|
}
|
|
599
2541
|
|
|
600
|
-
this.
|
|
601
|
-
|
|
602
|
-
if (!this.callbacks[_id]) {
|
|
603
|
-
this.callbacks[_id] = [];
|
|
604
|
-
}
|
|
605
|
-
this.callbacks[_id].push(callback);
|
|
606
|
-
}).catch(mongoErrorHandler);
|
|
2542
|
+
await this.queue.push(task);
|
|
2543
|
+
return task.uuid;
|
|
607
2544
|
}
|
|
608
2545
|
|
|
609
2546
|
/**
|
|
2547
|
+
* @internal
|
|
610
2548
|
* @memberOf MailTime
|
|
611
2549
|
* @name ___render
|
|
612
|
-
* @
|
|
613
|
-
* @param
|
|
614
|
-
* @
|
|
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
|
|
2553
|
+
* @returns {string}
|
|
615
2554
|
*/
|
|
616
2555
|
___render(_string, replacements) {
|
|
617
|
-
let i;
|
|
618
2556
|
let string = _string;
|
|
619
|
-
const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9
|
|
2557
|
+
const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
|
|
620
2558
|
if (matchHTML) {
|
|
621
|
-
for (i = 0; i < matchHTML.length; i++) {
|
|
622
|
-
|
|
623
|
-
|
|
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]}`);
|
|
624
2563
|
}
|
|
625
2564
|
}
|
|
626
2565
|
}
|
|
627
2566
|
|
|
628
|
-
const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9
|
|
2567
|
+
const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9\-_]+)\s?\}{2}/g);
|
|
629
2568
|
if (matchStr) {
|
|
630
|
-
for (i = 0; i < matchStr.length; i++) {
|
|
631
|
-
|
|
632
|
-
|
|
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, ''));
|
|
633
2573
|
}
|
|
634
2574
|
}
|
|
635
2575
|
}
|
|
@@ -637,22 +2577,482 @@ class MailTime {
|
|
|
637
2577
|
}
|
|
638
2578
|
|
|
639
2579
|
/**
|
|
2580
|
+
* @internal
|
|
2581
|
+
* @memberOf MailTime
|
|
2582
|
+
* @name ___compileMailOpts
|
|
2583
|
+
* @description Run various checks, compile options, and render template
|
|
2584
|
+
* @param {MailTimeTransport} transport - Current transport
|
|
2585
|
+
* @param {MailTimeTask} task - Email task record from Storage
|
|
2586
|
+
* @returns {MailTimeMailOptions}
|
|
2587
|
+
*/
|
|
2588
|
+
___compileMailOpts(transport, task) {
|
|
2589
|
+
if (!transport) {
|
|
2590
|
+
throw new Error('[mail-time] [sendMail] [___compileMailOpts] {transport} is not available or misconfigured!');
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
let compiledOpts = {};
|
|
2594
|
+
|
|
2595
|
+
if (isPlainObject(transport._options) && isPlainObject(transport._options.mailOptions)) {
|
|
2596
|
+
compiledOpts = deepMerge(compiledOpts, transport._options.mailOptions);
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
if (isPlainObject(transport.options) && isPlainObject(transport.options.mailOptions)) {
|
|
2600
|
+
compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
compiledOpts.html ??= '';
|
|
2604
|
+
compiledOpts.text ??= '';
|
|
2605
|
+
compiledOpts.subject ??= '';
|
|
2606
|
+
|
|
2607
|
+
const mailOptionsList = task.mailOptions || [];
|
|
2608
|
+
const isMulti = mailOptionsList.length > 1;
|
|
2609
|
+
|
|
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;
|
|
2617
|
+
} else {
|
|
2618
|
+
compiledOpts.html = rendered;
|
|
2619
|
+
}
|
|
2620
|
+
delete mailOption.html;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
if (mailOption.text) {
|
|
2624
|
+
const rendered = this.___render(mailOption.text, mailOption);
|
|
2625
|
+
if (isMulti) {
|
|
2626
|
+
compiledOpts.text += '\r\n' + rendered;
|
|
2627
|
+
} else {
|
|
2628
|
+
compiledOpts.text = rendered;
|
|
2629
|
+
}
|
|
2630
|
+
delete mailOption.text;
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
compiledOpts = deepMerge(compiledOpts, mailOption);
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
if (compiledOpts.html && (task.template || this.template)) {
|
|
2637
|
+
compiledOpts.html = this.___render((task.template || this.template), compiledOpts);
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
if (isMulti) {
|
|
2641
|
+
const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
|
|
2642
|
+
compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
if (!compiledOpts.from && this.from) {
|
|
2646
|
+
compiledOpts.from = this.from(transport);
|
|
2647
|
+
}
|
|
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
|
+
|
|
2656
|
+
return compiledOpts;
|
|
2657
|
+
}
|
|
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
|
+
|
|
2723
|
+
/**
|
|
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
|
|
640
2759
|
* @memberOf MailTime
|
|
641
|
-
* @name
|
|
642
|
-
* @
|
|
643
|
-
* @param
|
|
644
|
-
* @
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
2760
|
+
* @name ___send
|
|
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
|
|
2763
|
+
* @returns {Promise<void 0>}
|
|
2764
|
+
*/
|
|
2765
|
+
async ___send(task) {
|
|
2766
|
+
this.__debug('[private send]', task);
|
|
2767
|
+
try {
|
|
2768
|
+
if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
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
|
+
}
|
|
2785
|
+
|
|
2786
|
+
if (!isClaimed) {
|
|
2787
|
+
this.__debug('[private send] [queue.update] Stale claim, skipping', task.uuid);
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
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;
|
|
2799
|
+
}
|
|
2800
|
+
const transport = this.transports[transportIndex];
|
|
2801
|
+
|
|
2802
|
+
const compiledOpts = this.___compileMailOpts(transport, task);
|
|
2803
|
+
|
|
2804
|
+
await new Promise((resolve) => {
|
|
2805
|
+
transport.sendMail(compiledOpts, async (error, info) => {
|
|
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 {
|
|
2919
|
+
resolve();
|
|
2920
|
+
}
|
|
2921
|
+
});
|
|
652
2922
|
});
|
|
2923
|
+
} catch (e) {
|
|
2924
|
+
logError('Exception during runtime:', e);
|
|
2925
|
+
await this.___handleError(task, e, {});
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
/**
|
|
2930
|
+
* @internal
|
|
2931
|
+
* @memberOf MailTime
|
|
2932
|
+
* @name ___iterate
|
|
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}
|
|
2935
|
+
*/
|
|
2936
|
+
async ___iterate() {
|
|
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
|
+
}
|
|
653
3047
|
}
|
|
654
|
-
|
|
3048
|
+
return fromIdx;
|
|
655
3049
|
}
|
|
656
3050
|
}
|
|
657
3051
|
|
|
658
|
-
|
|
3052
|
+
exports.MailTime = MailTime;
|
|
3053
|
+
exports.MongoQueue = MongoQueue;
|
|
3054
|
+
exports.PostgresQueue = PostgresQueue;
|
|
3055
|
+
exports.RedisQueue = RedisQueue;
|
|
3056
|
+
exports.mailTimePreset = mailTimePreset;
|
|
3057
|
+
exports.presetNames = presetNames;
|
|
3058
|
+
exports.presets = presets;
|