mail-time 1.3.4 → 3.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 +520 -145
- package/adapters/blank-example.js +230 -0
- package/adapters/mongo.js +310 -0
- package/adapters/redis.js +302 -0
- package/helpers.js +11 -0
- package/index.cjs +1285 -0
- package/index.js +428 -383
- package/package.json +30 -11
- package/CHANGELOG.md +0 -1
- package/HISTORY.md +0 -1
package/index.cjs
ADDED
|
@@ -0,0 +1,1285 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const josk = require('josk');
|
|
4
|
+
const merge = require('deepmerge');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
const debug = (isDebug, ...args) => {
|
|
8
|
+
if (isDebug) {
|
|
9
|
+
console.info.call(console, '[DEBUG] [mail-time]', `${new Date}`, ...args);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const logError = (...args) => {
|
|
14
|
+
console.error.call(console, '[ERROR] [mail-time]', `${new Date}`, ...args);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ensure (create) index on MongoDB collection, catch and log exception if thrown
|
|
19
|
+
* @function ensureIndex
|
|
20
|
+
* @param {Collection} collection - Mongo's driver Collection instance
|
|
21
|
+
* @param {object} keys - Field and value pairs where the field is the index key and the value describes the type of index for that field
|
|
22
|
+
* @param {object} opts - Set of options that controls the creation of the index
|
|
23
|
+
* @returns {void 0}
|
|
24
|
+
*/
|
|
25
|
+
const ensureIndex = async (collection, keys, opts) => {
|
|
26
|
+
try {
|
|
27
|
+
await collection.createIndex(keys, opts);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (e.code === 85) {
|
|
30
|
+
let indexName;
|
|
31
|
+
const indexes = await collection.indexes();
|
|
32
|
+
for (const index of indexes) {
|
|
33
|
+
let drop = true;
|
|
34
|
+
for (const indexKey of Object.keys(keys)) {
|
|
35
|
+
if (typeof index.key[indexKey] === 'undefined') {
|
|
36
|
+
drop = false;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const indexKey of Object.keys(index.key)) {
|
|
42
|
+
if (typeof keys[indexKey] === 'undefined') {
|
|
43
|
+
drop = false;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (drop) {
|
|
49
|
+
indexName = index.name;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (indexName) {
|
|
55
|
+
await collection.dropIndex(indexName);
|
|
56
|
+
await collection.createIndex(keys, opts);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection._name || 'MongoDB'}" collection`, { keys, opts, details: e });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Class representing MongoDB Queue for MailTime */
|
|
65
|
+
class MongoQueue {
|
|
66
|
+
/**
|
|
67
|
+
* Create a MongoQueue instance
|
|
68
|
+
* @param {object} opts - configuration object
|
|
69
|
+
* @param {Db} opts.db - Required, Mongo's `Db` instance, like one returned from `MongoClient#db()` method
|
|
70
|
+
* @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application
|
|
71
|
+
*/
|
|
72
|
+
constructor (opts) {
|
|
73
|
+
this.name = 'mongo-queue';
|
|
74
|
+
if (!opts || typeof opts !== 'object' || opts === null) {
|
|
75
|
+
throw new TypeError('[mail-time] Configuration object must be passed into MongoQueue constructor');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!opts.db) {
|
|
79
|
+
throw new Error('[mail-time] [MongoQueue] requires MongoDB database {db} option, like returned from `MongoClient.connect`');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '';
|
|
83
|
+
this.db = opts.db;
|
|
84
|
+
this.collection = opts.db.collection(`__mailTimeQueue__${this.prefix}`);
|
|
85
|
+
ensureIndex(this.collection, { uuid: 1 }, { background: false });
|
|
86
|
+
ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, to: 1, sendAt: 1 }, { background: false });
|
|
87
|
+
ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, sendAt: 1, tries: 1 }, { background: false });
|
|
88
|
+
|
|
89
|
+
// MongoDB Collection Schema:
|
|
90
|
+
// _id
|
|
91
|
+
// uuid {string}
|
|
92
|
+
// to {string|[string]}
|
|
93
|
+
// tries {number} - qty of send attempts
|
|
94
|
+
// sendAt {number} - When letter should be sent
|
|
95
|
+
// isSent {boolean} - Email status
|
|
96
|
+
// isCancelled {boolean} - `true` if email was cancelled before it was sent
|
|
97
|
+
// isFailed {boolean} - `true` if email has failed to send
|
|
98
|
+
// template {string} - Template for this email
|
|
99
|
+
// transport {number} - Last used transport
|
|
100
|
+
// concatSubject {string|boolean} - Email concatenation subject
|
|
101
|
+
// ---
|
|
102
|
+
// mailOptions {[object]} - Array of nodeMailer's `mailOptions`
|
|
103
|
+
// mailOptions.to {string|[string]} - [REQUIRED]
|
|
104
|
+
// mailOptions.from {string}
|
|
105
|
+
// mailOptions.text {string|boolean}
|
|
106
|
+
// mailOptions.html {string}
|
|
107
|
+
// mailOptions.subject {string}
|
|
108
|
+
// mailOptions.Other nodeMailer `sendMail` options...
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @async
|
|
113
|
+
* @memberOf MongoQueue
|
|
114
|
+
* @name ping
|
|
115
|
+
* @description Check connection to Storage
|
|
116
|
+
* @returns {Promise<object>}
|
|
117
|
+
*/
|
|
118
|
+
async ping() {
|
|
119
|
+
if (!this.mailTimeInstance) {
|
|
120
|
+
return {
|
|
121
|
+
status: 'Service Unavailable',
|
|
122
|
+
code: 503,
|
|
123
|
+
statusCode: 503,
|
|
124
|
+
error: new Error('MailTime instance not yet assigned to {mailTimeInstance} of Queue Adapter context'),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const ping = await this.db.command({ ping: 1 });
|
|
130
|
+
if (ping?.ok === 1) {
|
|
131
|
+
return {
|
|
132
|
+
status: 'OK',
|
|
133
|
+
code: 200,
|
|
134
|
+
statusCode: 200,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
} catch (pingError) {
|
|
138
|
+
return {
|
|
139
|
+
status: 'Internal Server Error',
|
|
140
|
+
code: 500,
|
|
141
|
+
statusCode: 500,
|
|
142
|
+
error: pingError
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
status: 'Service Unavailable',
|
|
148
|
+
code: 503,
|
|
149
|
+
statusCode: 503,
|
|
150
|
+
error: new Error('Service Unavailable')
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @memberOf MongoQueue
|
|
156
|
+
* @name iterate
|
|
157
|
+
* @description iterate over queued tasks passing to `mailTimeInstance.___send` method
|
|
158
|
+
* @returns {void 0}
|
|
159
|
+
*/
|
|
160
|
+
async iterate() {
|
|
161
|
+
try {
|
|
162
|
+
const cursor = this.collection.find({
|
|
163
|
+
isSent: false,
|
|
164
|
+
isFailed: false,
|
|
165
|
+
isCancelled: false,
|
|
166
|
+
sendAt: {
|
|
167
|
+
$lte: Date.now()
|
|
168
|
+
},
|
|
169
|
+
tries: {
|
|
170
|
+
$lt: this.mailTimeInstance.maxTries
|
|
171
|
+
}
|
|
172
|
+
}, {
|
|
173
|
+
projection: {
|
|
174
|
+
_id: 1,
|
|
175
|
+
uuid: 1,
|
|
176
|
+
tries: 1,
|
|
177
|
+
template: 1,
|
|
178
|
+
transport: 1,
|
|
179
|
+
isSent: 1,
|
|
180
|
+
isFailed: 1,
|
|
181
|
+
isCancelled: 1,
|
|
182
|
+
mailOptions: 1,
|
|
183
|
+
concatSubject: 1,
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
while (await cursor.hasNext()) {
|
|
188
|
+
await this.mailTimeInstance.___send(await cursor.next());
|
|
189
|
+
}
|
|
190
|
+
await cursor.close();
|
|
191
|
+
} catch (iterateError) {
|
|
192
|
+
logError('[iterate] [while/await] [iterateError]', iterateError);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @async
|
|
198
|
+
* @memberOf MongoQueue
|
|
199
|
+
* @name getPendingTo
|
|
200
|
+
* @description get queued task by `to` field (addressee)
|
|
201
|
+
* @param to {string} - email address
|
|
202
|
+
* @param sendAt {number} - timestamp
|
|
203
|
+
* @returns {Promise<object|null>}
|
|
204
|
+
*/
|
|
205
|
+
async getPendingTo(to, sendAt) {
|
|
206
|
+
if (typeof to !== 'string' || typeof sendAt !== 'number') {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return await this.collection.findOne({
|
|
211
|
+
to: to,
|
|
212
|
+
isSent: false,
|
|
213
|
+
isFailed: false,
|
|
214
|
+
isCancelled: false,
|
|
215
|
+
sendAt: {
|
|
216
|
+
$lte: sendAt
|
|
217
|
+
}
|
|
218
|
+
}, {
|
|
219
|
+
projection: {
|
|
220
|
+
_id: 1,
|
|
221
|
+
to: 1,
|
|
222
|
+
uuid: 1,
|
|
223
|
+
tries: 1,
|
|
224
|
+
isSent: 1,
|
|
225
|
+
isFailed: 1,
|
|
226
|
+
isCancelled: 1,
|
|
227
|
+
mailOptions: 1,
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @async
|
|
234
|
+
* @memberOf MongoQueue
|
|
235
|
+
* @name push
|
|
236
|
+
* @description push task to the queue/storage
|
|
237
|
+
* @param task {object} - task's object
|
|
238
|
+
* @returns {Promise<void 0>}
|
|
239
|
+
*/
|
|
240
|
+
async push(task) {
|
|
241
|
+
if (typeof task !== 'object') {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (task.sendAt instanceof Date) {
|
|
246
|
+
task.sendAt = +task.sendAt;
|
|
247
|
+
}
|
|
248
|
+
await this.collection.insertOne(task);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @async
|
|
253
|
+
* @memberOf MongoQueue
|
|
254
|
+
* @name cancel
|
|
255
|
+
* @description cancel scheduled email
|
|
256
|
+
* @param uuid {string} - email's uuid
|
|
257
|
+
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
|
|
258
|
+
*/
|
|
259
|
+
async cancel(uuid) {
|
|
260
|
+
if (typeof uuid !== 'string') {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const task = await this.collection.findOne({ uuid }, {
|
|
265
|
+
projection: {
|
|
266
|
+
_id: 1,
|
|
267
|
+
uuid: 1,
|
|
268
|
+
isSent: 1,
|
|
269
|
+
isCancelled: 1,
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (!task || task.isSent === true || task.isCancelled === true) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!this.mailTimeInstance.keepHistory) {
|
|
278
|
+
return await this.remove(task);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return await this.update(task, {
|
|
282
|
+
isCancelled: true,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @async
|
|
288
|
+
* @memberOf MongoQueue
|
|
289
|
+
* @name remove
|
|
290
|
+
* @description remove task from queue
|
|
291
|
+
* @param task {object} - task's object
|
|
292
|
+
* @returns {Promise<boolean>} returns `true` if removed or `false` if not found
|
|
293
|
+
*/
|
|
294
|
+
async remove(task) {
|
|
295
|
+
if (typeof task !== 'object') {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return (await this.collection.deleteOne({ _id: task._id }))?.deletedCount >= 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* @async
|
|
304
|
+
* @memberOf MongoQueue
|
|
305
|
+
* @name update
|
|
306
|
+
* @description remove task from queue
|
|
307
|
+
* @param task {object} - task's object
|
|
308
|
+
* @param updateObj {object} - fields with new values to update
|
|
309
|
+
* @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
|
|
310
|
+
*/
|
|
311
|
+
async update(task, updateObj) {
|
|
312
|
+
if (typeof task !== 'object' || typeof updateObj !== 'object') {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return (await this.collection.updateOne({
|
|
317
|
+
_id: task._id
|
|
318
|
+
}, {
|
|
319
|
+
$set: updateObj
|
|
320
|
+
}))?.modifiedCount >= 1;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Class representing Redis Queue for MailTime */
|
|
325
|
+
class RedisQueue {
|
|
326
|
+
/**
|
|
327
|
+
* Create a RedisQueue instance
|
|
328
|
+
* @param {object} opts - configuration object
|
|
329
|
+
* @param {RedisClient} opts.client - Required, Redis'es `RedisClient` instance, like one returned from `await redis.createClient().connect()` method
|
|
330
|
+
* @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application
|
|
331
|
+
*/
|
|
332
|
+
constructor (opts) {
|
|
333
|
+
this.name = 'redis-queue';
|
|
334
|
+
if (!opts || typeof opts !== 'object' || opts === null) {
|
|
335
|
+
throw new TypeError('[mail-time] Configuration object must be passed into RedisQueue constructor');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!opts.client) {
|
|
339
|
+
throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : 'default';
|
|
343
|
+
this.uniqueName = `mailtime:${this.prefix}`;
|
|
344
|
+
this.client = opts.client;
|
|
345
|
+
|
|
346
|
+
// 3 types of keys are stored in Redis:
|
|
347
|
+
// 'letter' - JSON with email details
|
|
348
|
+
// 'sendat' - Timestamp used to scan/find/iterate over scheduled emails
|
|
349
|
+
// 'concatletter' — uuid of "pending" email for concatenation used when {concatEmails: true}
|
|
350
|
+
|
|
351
|
+
// Stored JSON structure:
|
|
352
|
+
// to {string|[string]}
|
|
353
|
+
// uuid {string}
|
|
354
|
+
// tries {number} - qty of send attempts
|
|
355
|
+
// sendAt {number} - When letter should be sent
|
|
356
|
+
// isSent {boolean} - Email status
|
|
357
|
+
// isCancelled {boolean} - `true` if email was cancelled before it was sent
|
|
358
|
+
// isFailed {boolean} - `true` if email has failed to send
|
|
359
|
+
// template {string} - Template for this email
|
|
360
|
+
// transport {number} - Last used transport
|
|
361
|
+
// concatSubject {string|boolean} - Email concatenation subject
|
|
362
|
+
// ---
|
|
363
|
+
// mailOptions {[object]} - Array of nodeMailer's `mailOptions`
|
|
364
|
+
// mailOptions.to {string|[string]} - [REQUIRED]
|
|
365
|
+
// mailOptions.from {string}
|
|
366
|
+
// mailOptions.text {string|boolean}
|
|
367
|
+
// mailOptions.html {string}
|
|
368
|
+
// mailOptions.subject {string}
|
|
369
|
+
// mailOptions.Other nodeMailer `sendMail` options...
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @async
|
|
374
|
+
* @memberOf RedisQueue
|
|
375
|
+
* @name ping
|
|
376
|
+
* @description Check connection to Storage
|
|
377
|
+
* @returns {Promise<object>}
|
|
378
|
+
*/
|
|
379
|
+
async ping() {
|
|
380
|
+
if (!this.mailTimeInstance) {
|
|
381
|
+
return {
|
|
382
|
+
status: 'Service Unavailable',
|
|
383
|
+
code: 503,
|
|
384
|
+
statusCode: 503,
|
|
385
|
+
error: new Error('MailTime instance not yet assigned to {mailTimeInstance} of Queue Adapter context'),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const ping = await this.client.ping();
|
|
391
|
+
if (ping === 'PONG') {
|
|
392
|
+
return {
|
|
393
|
+
status: 'OK',
|
|
394
|
+
code: 200,
|
|
395
|
+
statusCode: 200,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
} catch (pingError) {
|
|
399
|
+
return {
|
|
400
|
+
status: 'Internal Server Error',
|
|
401
|
+
code: 500,
|
|
402
|
+
statusCode: 500,
|
|
403
|
+
error: pingError
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
status: 'Service Unavailable',
|
|
409
|
+
code: 503,
|
|
410
|
+
statusCode: 503,
|
|
411
|
+
error: new Error('Service Unavailable')
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* @memberOf RedisQueue
|
|
417
|
+
* @name iterate
|
|
418
|
+
* @description iterate over queued tasks passing to `mailTimeInstance.___send` method
|
|
419
|
+
* @returns {void 0}
|
|
420
|
+
*/
|
|
421
|
+
async iterate() {
|
|
422
|
+
try {
|
|
423
|
+
const now = Date.now();
|
|
424
|
+
const cursor = this.client.scanIterator({
|
|
425
|
+
TYPE: 'string',
|
|
426
|
+
MATCH: this.__getKey('*', 'sendat'),
|
|
427
|
+
COUNT: 9999,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
for await (const sendatKey of cursor) {
|
|
431
|
+
if (parseInt(await this.client.get(sendatKey)) <= now) {
|
|
432
|
+
await this.mailTimeInstance.___send(JSON.parse(await this.client.get(this.__getKey(sendatKey.split(':')[3]))));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} catch (iterateError) {
|
|
436
|
+
logError('[iterate] [for/await] [iterateError]', iterateError);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* @async
|
|
442
|
+
* @memberOf RedisQueue
|
|
443
|
+
* @name getPendingTo
|
|
444
|
+
* @description get queued task by `to` field (addressee)
|
|
445
|
+
* @param to {string} - email address
|
|
446
|
+
* @param sendAt {number} - timestamp
|
|
447
|
+
* @returns {Promise<object|null>}
|
|
448
|
+
*/
|
|
449
|
+
async getPendingTo(to, sendAt) {
|
|
450
|
+
if (typeof to !== 'string' || typeof sendAt !== 'number') {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const concatKey = this.__getKey(to, 'concatletter');
|
|
455
|
+
let exists = await this.client.exists(concatKey);
|
|
456
|
+
if (!exists) {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const uuid = await this.client.get(concatKey);
|
|
461
|
+
const letterKey = this.__getKey(uuid, 'letter');
|
|
462
|
+
exists = await this.client.exists(letterKey);
|
|
463
|
+
if (!exists) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const task = JSON.parse(await this.client.get(letterKey));
|
|
468
|
+
if (!task || task.isSent === true || task.isCancelled === true || task.isFailed === true) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return task;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* @async
|
|
477
|
+
* @memberOf RedisQueue
|
|
478
|
+
* @name push
|
|
479
|
+
* @description push task to the queue/storage
|
|
480
|
+
* @param task {object} - task's object
|
|
481
|
+
* @returns {Promise<void 0>}
|
|
482
|
+
*/
|
|
483
|
+
async push(task) {
|
|
484
|
+
if (typeof task !== 'object') {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (task.sendAt instanceof Date) {
|
|
489
|
+
task.sendAt = +task.sendAt;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
await this.client.set(this.__getKey(task.uuid, 'letter'), JSON.stringify(task));
|
|
493
|
+
await this.client.set(this.__getKey(task.uuid, 'sendat'), `${task.sendAt}`);
|
|
494
|
+
if (task.to) {
|
|
495
|
+
await this.client.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
|
|
496
|
+
PXAT: task.sendAt - 128
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* @async
|
|
503
|
+
* @memberOf RedisQueue
|
|
504
|
+
* @name cancel
|
|
505
|
+
* @description cancel scheduled email
|
|
506
|
+
* @param uuid {string} - email's uuid
|
|
507
|
+
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
|
|
508
|
+
*/
|
|
509
|
+
async cancel(uuid) {
|
|
510
|
+
if (typeof uuid !== 'string') {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
await this.client.del(this.__getKey(uuid, 'sendat'));
|
|
515
|
+
const letterKey = this.__getKey(uuid, 'letter');
|
|
516
|
+
const exists = await this.client.exists(letterKey);
|
|
517
|
+
if (!exists) {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const task = JSON.parse(await this.client.get(letterKey));
|
|
522
|
+
if (!task || task.isSent === true || task.isCancelled === true) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (!this.mailTimeInstance.keepHistory) {
|
|
527
|
+
return await this.remove(task);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return await this.update(task, {
|
|
531
|
+
isCancelled: true,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* @async
|
|
537
|
+
* @memberOf RedisQueue
|
|
538
|
+
* @name remove
|
|
539
|
+
* @description remove task from queue
|
|
540
|
+
* @param task {object} - task's object
|
|
541
|
+
* @returns {Promise<boolean>} returns `true` if removed or `false` if not found
|
|
542
|
+
*/
|
|
543
|
+
async remove(task) {
|
|
544
|
+
if (typeof task !== 'object' || typeof task.uuid !== 'string') {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const letterKey = this.__getKey(task.uuid, 'letter');
|
|
549
|
+
const exists = await this.client.exists(letterKey);
|
|
550
|
+
if (!exists) {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
await this.client.del([
|
|
555
|
+
letterKey,
|
|
556
|
+
this.__getKey(task.uuid, 'sendat'),
|
|
557
|
+
]);
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* @async
|
|
563
|
+
* @memberOf RedisQueue
|
|
564
|
+
* @name update
|
|
565
|
+
* @description remove task from queue
|
|
566
|
+
* @param task {object} - task's object
|
|
567
|
+
* @param updateObj {object} - fields with new values to update
|
|
568
|
+
* @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
|
|
569
|
+
*/
|
|
570
|
+
async update(task, updateObj) {
|
|
571
|
+
if (typeof task !== 'object' || typeof task.uuid !== 'string' || typeof updateObj !== 'object') {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const letterKey = this.__getKey(task.uuid, 'letter');
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
const exists = await this.client.exists(letterKey);
|
|
579
|
+
if (!exists) {
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
const updatedTask = { ...task, ...updateObj };
|
|
583
|
+
await this.client.set(letterKey, JSON.stringify(updatedTask));
|
|
584
|
+
|
|
585
|
+
const sendatKey = this.__getKey(task.uuid, 'sendat');
|
|
586
|
+
if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
|
|
587
|
+
await this.client.del(sendatKey);
|
|
588
|
+
} else if (task.sendAt) {
|
|
589
|
+
await this.client.set(sendatKey, `${+task.sendAt}`);
|
|
590
|
+
}
|
|
591
|
+
return true;
|
|
592
|
+
} catch (opError) {
|
|
593
|
+
logError('[update] [try/catch] [opError]', opError);
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* @memberOf RedisQueue
|
|
601
|
+
* @name __getKey
|
|
602
|
+
* @description helper to generate scoped key
|
|
603
|
+
* @param uuid {string} - letter's uuid
|
|
604
|
+
* @param type {string} - "letter" or "sendat" or "concatletter"
|
|
605
|
+
* @returns {string} returns key used by Redis
|
|
606
|
+
*/
|
|
607
|
+
__getKey(uuid, type = 'letter') {
|
|
608
|
+
if (!this.__keyTypes.includes(type)) {
|
|
609
|
+
throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
|
|
610
|
+
}
|
|
611
|
+
return `${this.uniqueName}:${type}:${uuid}`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* @memberOf RedisQueue
|
|
616
|
+
* @name __keyTypes
|
|
617
|
+
* @description list of supported key type
|
|
618
|
+
* @returns {string} returns key used by Redis
|
|
619
|
+
*/
|
|
620
|
+
__keyTypes = ['letter', 'sendat', 'concatletter'];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const noop = () => {};
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Check if entities of various types are equal
|
|
627
|
+
* including edge cases like unordered Objects and Array
|
|
628
|
+
* @function equals
|
|
629
|
+
* @param {mix} a
|
|
630
|
+
* @param {mix} b
|
|
631
|
+
* @returns {boolean}
|
|
632
|
+
*/
|
|
633
|
+
const equals = (a, b) => {
|
|
634
|
+
let i;
|
|
635
|
+
if (a === b) {
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (!a || !b) {
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (!(typeof a === 'object' && typeof b === 'object')) {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (a instanceof Date && b instanceof Date) {
|
|
648
|
+
return a.valueOf() === b.valueOf();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (a instanceof Array) {
|
|
652
|
+
if (!(b instanceof Array)) {
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (a.length !== b.length) {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const _a = a.slice();
|
|
661
|
+
const _b = b.slice();
|
|
662
|
+
let j;
|
|
663
|
+
for (i = _a.length - 1; i >= 0; i--) {
|
|
664
|
+
let result = false;
|
|
665
|
+
for (j = _b.length - 1; j >= 0; j--) {
|
|
666
|
+
if (equals(_a[i], _b[j])) {
|
|
667
|
+
result = true;
|
|
668
|
+
_a.splice(i, 1);
|
|
669
|
+
_b.splice(j, 1);
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (!result) {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
i = 0;
|
|
682
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
683
|
+
const akeys = Object.keys(a);
|
|
684
|
+
const bkeys = Object.keys(b);
|
|
685
|
+
|
|
686
|
+
if (akeys.length !== bkeys.length) {
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
for (i = akeys.length - 1; i >= 0; i--) {
|
|
691
|
+
if (!hasOwnProperty.call(b, akeys[i])) {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (!equals(a[akeys[i]], b[akeys[i]])) {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
return false;
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml><meta content="text/html; charset=utf-8"http-equiv=Content-Type><meta content="width=device-width,initial-scale=1"name=viewport><title>{{subject}}</title><style>body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;font-family:Tiempos,Georgia,Times,serif;font-weight:400;width:100%;height:100%;background:#fff;font-size:15px;color:#000;line-height:1.5}a{text-decoration:underline;border:0;color:#000;outline:0;color:inherit}a:hover{text-decoration:none}a[href^=sms],a[href^=tel]{text-decoration:none;color:#000;cursor:default}a img{border:none;text-decoration:none}td{font-family:Tiempos,Georgia,Times,serif;font-weight:400}hr{height:1px;border:none;width:100%;margin:0;margin-top:25px;margin-bottom:25px;background-color:#ECECEC}h1,h2,h3,h4,h5,h6{font-family:HelveticaNeue,"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;line-height:normal;margin-top:35px;margin-bottom:4px;margin-left:0;margin-right:0}h1{margin:23px 15px;font-size:25px}h2{margin-top:15px;font-size:21px}h3{font-weight:400;font-size:19px;border-bottom:1px solid #ECECEC}h4{font-weight:400;font-size:18px}h5{font-weight:400;font-size:17px}h6{font-weight:600;font-size:16px}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{text-decoration:none}pre{font-family:Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;display:block;font-size:13px;padding:9.5px;margin:0 0 10px;line-height:1.42;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px;text-align:left!important;max-width:100%;white-space:pre-wrap;width:auto;overflow:auto}code{font-size:13px;font-family:font-family: Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;border:1px solid rgba(0,0,0,.223);border-radius:2px;padding:1px 2px;word-break:break-all;word-wrap:break-word}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border:none;border-radius:0;word-break:break-all;word-wrap:break-word}td{text-align:center}table{border-collapse:collapse!important}.force-full-width{width:100%!important}</style><style media=screen>@media screen{h1,h2,h3,h4,h5,h6{font-family:\'Helvetica Neue\',Arial,sans-serif!important}td{font-family:Tiempos,Georgia,Times,serif!important}code,pre{font-family:Consolas,Menlo,Monaco,\'Lucida Console\',\'Liberation Mono\',\'DejaVu Sans Mono\',\'Bitstream Vera Sans Mono\',\'Courier New\',monospace,sans-serif!important}}</style><style media="only screen and (max-width:480px)">@media only screen and (max-width:480px){table[class=w320]{width:100%!important}}</style><body bgcolor=#FFFFFF class=body style=padding:0;margin:0;display:block;background:#fff;-webkit-text-size-adjust:none><table cellpadding=0 cellspacing=0 width=100% align=center><tr><td align=center valign=top bgcolor=#FFFFFF width=100%><center><table cellpadding=0 cellspacing=0 width=600 style="margin:0 auto"class=w320><tr><td align=center valign=top><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto;border-bottom:1px solid #ddd"bgcolor=#ECECEC><tr><td><h1>{{{subject}}}</h1></table><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"bgcolor=#F2F2F2><tr><td><center><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"><tr><td align=left style="text-align:left;padding:30px 25px">{{{html}}}</table></center></table></table></center></table>';
|
|
706
|
+
|
|
707
|
+
/** Class of MailTime */
|
|
708
|
+
class MailTime {
|
|
709
|
+
/**
|
|
710
|
+
* Create a MailTime instance
|
|
711
|
+
* @param {object} opts - configuration object
|
|
712
|
+
* @param {RedisQueue|MongoQueue|CustomQueue} opts.queue - Queue Storage Driver instance
|
|
713
|
+
* @param {string} [opts.type] - "server" or "client" type of MailTime instance
|
|
714
|
+
* @param {function} [opts.from] - A function returning *String* for `from` header, format: `"MyApp" <user@example.com>`
|
|
715
|
+
* @param {[object]} [opts.transports] - An array of `nodemailer`'s transports, returned from `nodemailer.createTransport({})`; Required for `{type: 'server'}`
|
|
716
|
+
* @param {string} [opts.strategy] - `backup` or `balancer`
|
|
717
|
+
* @param {number} [opts.failsToNext] - After how many failed "send attempts" switch to the next transport? Applied only for `backup` strategy, default - `4`
|
|
718
|
+
* @param {number} [opts.retries] - How many times resend failed emails
|
|
719
|
+
* @param {number} [opts.retryDelay] - Interval in milliseconds between send re-tries
|
|
720
|
+
* @param {boolean} [opts.keepHistory] - Keep queue task as it is in the database
|
|
721
|
+
* @param {boolean} [opts.concatEmails] - Concatenate email by `to` field, default - `false`
|
|
722
|
+
* @param {string} [opts.concatSubject] - Email subject used in concatenated email, default - `Multiple notifications`
|
|
723
|
+
* @param {string} [opts.concatDelimiter] - HTML or plain string delimiter used between concatenated email, default - `<hr>`
|
|
724
|
+
* @param {number} [opts.concatDelay] - HTML or plain string delimiter used between concatenated email, default - `<hr>`
|
|
725
|
+
* @param {number} [opts.revolvingInterval] - Interval in *milliseconds* in between queue checks, default - `256`
|
|
726
|
+
* @param {object|RedisAdapter|MongoAdapter|CustomAdapter} [opts.josk.adapter] - Interval in milliseconds between send re-tries
|
|
727
|
+
* @param {string} [opts.josk.adapter.type] - One of `mongo` or `redis`
|
|
728
|
+
* @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application; By default prefix inherited from MailTime instance
|
|
729
|
+
*/
|
|
730
|
+
constructor (opts) {
|
|
731
|
+
if (!opts || typeof opts !== 'object' || opts === null) {
|
|
732
|
+
throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (!opts.queue || typeof opts.queue !== 'object') {
|
|
736
|
+
throw new Error('[mail-time] {queue} option is required', {
|
|
737
|
+
description: 'MailTime requires MongoQueue, RedisQueue, or CustomQueue to connect to an intermediate database'
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
this.queue = opts.queue;
|
|
742
|
+
this.queue.mailTimeInstance = this;
|
|
743
|
+
const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
|
|
744
|
+
|
|
745
|
+
for (let i = queueMethods.length - 1; i >= 0; i--) {
|
|
746
|
+
if (typeof this.queue[queueMethods[i]] !== 'function') {
|
|
747
|
+
throw new Error(`{queue} instance is missing {${queueMethods[i]}} method that is required!`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
this.debug = (opts.debug !== true) ? false : true;
|
|
752
|
+
this._debug = (...args) => {
|
|
753
|
+
debug(this.debug, ...args);
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
this.type = (typeof opts.type !== 'string' || (opts.type !== 'client' && opts.type !== 'server')) ? 'server' : opts.type;
|
|
757
|
+
this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '';
|
|
758
|
+
|
|
759
|
+
if (typeof opts.retries === 'number') {
|
|
760
|
+
this.maxTries = opts.retries + 1;
|
|
761
|
+
} else if (typeof opts.maxTries === 'number') {
|
|
762
|
+
this.maxTries = (opts.maxTries < 1) ? 1 : 0;
|
|
763
|
+
} else {
|
|
764
|
+
this.maxTries = 60;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (typeof opts.retryDelay === 'number') {
|
|
768
|
+
this.retryDelay = opts.retryDelay;
|
|
769
|
+
} else if (typeof opts.interval === 'number') {
|
|
770
|
+
this.retryDelay = opts.interval * 1000;
|
|
771
|
+
} else {
|
|
772
|
+
this.retryDelay = 60000;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
|
|
776
|
+
this.keepHistory = (typeof opts.keepHistory === 'boolean') ? opts.keepHistory : false;
|
|
777
|
+
this.onSent = opts.onSent || noop;
|
|
778
|
+
this.onError = opts.onError || noop;
|
|
779
|
+
|
|
780
|
+
this.revolvingInterval = opts.revolvingInterval || 1536;
|
|
781
|
+
|
|
782
|
+
this.failsToNext = (typeof opts.failsToNext === 'number') ? opts.failsToNext : 4;
|
|
783
|
+
this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup';
|
|
784
|
+
this.transports = opts.transports || [];
|
|
785
|
+
this.transport = 0;
|
|
786
|
+
this.from = (() => {
|
|
787
|
+
if (typeof opts.from === 'string') {
|
|
788
|
+
return () => {
|
|
789
|
+
return opts.from;
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (typeof opts.from === 'function') {
|
|
794
|
+
return opts.from;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return false;
|
|
798
|
+
})();
|
|
799
|
+
|
|
800
|
+
this.concatEmails = (opts.concatEmails !== true) ? false : true;
|
|
801
|
+
this.concatSubject = (opts.concatSubject && typeof opts.concatSubject === 'string') ? opts.concatSubject : 'Multiple notifications';
|
|
802
|
+
this.concatDelimiter = (opts.concatDelimiter && typeof opts.concatDelimiter === 'string') ? opts.concatDelimiter : '<hr>';
|
|
803
|
+
|
|
804
|
+
if (typeof opts.concatDelay === 'number') {
|
|
805
|
+
this.concatDelay = opts.concatDelay;
|
|
806
|
+
} else if (typeof opts.concatThrottling === 'number') {
|
|
807
|
+
this.concatDelay = opts.concatThrottling * 1000;
|
|
808
|
+
} else {
|
|
809
|
+
this.concatDelay = 60000;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
this._debug('DEBUG ON {debug: true}');
|
|
813
|
+
this._debug(`INITIALIZING [type: ${this.type}]`);
|
|
814
|
+
this._debug(`INITIALIZING [strategy: ${this.strategy}]`);
|
|
815
|
+
this._debug(`INITIALIZING [josk.adapter: ${opts?.josk?.adapter}]`);
|
|
816
|
+
this._debug(`INITIALIZING [prefix: ${this.prefix}]`);
|
|
817
|
+
this._debug(`INITIALIZING [retries: ${this.retries}]`);
|
|
818
|
+
this._debug(`INITIALIZING [failsToNext: ${this.failsToNext}]`);
|
|
819
|
+
this._debug(`INITIALIZING [onError: ${this.onError}]`);
|
|
820
|
+
this._debug(`INITIALIZING [onSent: ${this.onSent}]`);
|
|
821
|
+
|
|
822
|
+
/** SERVER-SPECIFIC CHECKS AND CONFIG */
|
|
823
|
+
if (this.type === 'server') {
|
|
824
|
+
if (this.transports.constructor !== Array || !this.transports.length) {
|
|
825
|
+
throw new Error('[mail-time] {transports} is required for {type: "server"} and must be an Array, like returned from `nodemailer.createTransport`');
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (typeof opts.josk !== 'object') {
|
|
829
|
+
throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}');
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (typeof opts.josk.adapter !== 'object') {
|
|
833
|
+
throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class');
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
this.josk = { ...opts.josk };
|
|
837
|
+
|
|
838
|
+
if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'mongo') {
|
|
839
|
+
if (!opts.josk.adapter.db) {
|
|
840
|
+
throw new Error('[mail-time] {josk.adapter.db} option required for {josk.adapter.type: "mongo"}');
|
|
841
|
+
}
|
|
842
|
+
this.josk.adapter = new josk.MongoAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'redis') {
|
|
846
|
+
if (!opts.josk.adapter.client) {
|
|
847
|
+
throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "redis"}');
|
|
848
|
+
}
|
|
849
|
+
this.josk.adapter = new josk.RedisAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
this.josk.minRevolvingDelay = (typeof opts.josk.minRevolvingDelay === 'number') ? opts.josk.minRevolvingDelay : 512;
|
|
853
|
+
this.josk.maxRevolvingDelay = (typeof opts.josk.maxRevolvingDelay === 'number') ? opts.josk.maxRevolvingDelay : 2048;
|
|
854
|
+
this.josk.zombieTime = (typeof opts.josk.zombieTime === 'number') ? opts.josk.zombieTime : 32786;
|
|
855
|
+
|
|
856
|
+
this.scheduler = new josk.JoSk({
|
|
857
|
+
debug: this.debug,
|
|
858
|
+
...this.josk,
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
process.nextTick(async () => {
|
|
862
|
+
if ((await this.scheduler.ping()).status !== 'OK') {
|
|
863
|
+
throw new Error('[mail-time] [MailTime#ping] can not connect to storage, make sure it is available and properly configured');
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
static get Template() {
|
|
872
|
+
return DEFAULT_TEMPLATE;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
static set Template(newVal) {
|
|
876
|
+
DEFAULT_TEMPLATE = newVal;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* @async
|
|
881
|
+
* @memberOf MailTime
|
|
882
|
+
* @name ping
|
|
883
|
+
* @description Check package readiness and connection to Storage
|
|
884
|
+
* @returns {Promise<object>}
|
|
885
|
+
* @throws {mix}
|
|
886
|
+
*/
|
|
887
|
+
async ping() {
|
|
888
|
+
this._debug('[ping]');
|
|
889
|
+
const schedulerPing = await this.scheduler.ping();
|
|
890
|
+
if (schedulerPing.status !== 'OK') {
|
|
891
|
+
return schedulerPing;
|
|
892
|
+
}
|
|
893
|
+
return await this.queue.ping();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* @memberOf MailTime
|
|
898
|
+
* @name send
|
|
899
|
+
* @description alias of `sendMail`
|
|
900
|
+
* @returns {Promise<string>} uuid of the email
|
|
901
|
+
*/
|
|
902
|
+
async send(opts) {
|
|
903
|
+
this._debug('[send]', opts);
|
|
904
|
+
return await this.sendMail(opts);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* @async
|
|
909
|
+
* @memberOf MailTime
|
|
910
|
+
* @name sendMail
|
|
911
|
+
* @description add email to the queue or append to existing letter if {concatEmails: true}
|
|
912
|
+
* @param opts {object} - email options
|
|
913
|
+
* @param opts.sendAt {Date|number} - When email should be sent
|
|
914
|
+
* @param opts.template {string} - Email-specific template
|
|
915
|
+
* @param opts[key] {mix} - Other NodeMailer's options
|
|
916
|
+
* @returns {Promise<string>} uuid of the email
|
|
917
|
+
* @throws {Error}
|
|
918
|
+
*/
|
|
919
|
+
async sendMail(opts = {}) {
|
|
920
|
+
this._debug('[sendMail]', opts);
|
|
921
|
+
if (!opts.html && !opts.text) {
|
|
922
|
+
throw new Error('`html` nor `text` field is presented, at least one of those fields is required');
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
let sendAt = opts.sendAt;
|
|
926
|
+
if (!sendAt) {
|
|
927
|
+
sendAt = Date.now();
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (sendAt instanceof Date) {
|
|
931
|
+
sendAt = +sendAt;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (typeof sendAt !== 'number') {
|
|
935
|
+
sendAt = Date.now();
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
let template = opts.template;
|
|
939
|
+
if (typeof template !== 'string') {
|
|
940
|
+
template = false;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
let concatSubject = opts.concatSubject;
|
|
944
|
+
if (typeof concatSubject !== 'string') {
|
|
945
|
+
concatSubject = false;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const mailOptions = { ...opts };
|
|
949
|
+
delete mailOptions.sendAt;
|
|
950
|
+
delete mailOptions.template;
|
|
951
|
+
delete mailOptions.concatSubject;
|
|
952
|
+
|
|
953
|
+
if (typeof mailOptions.to !== 'string' && (!(mailOptions.to instanceof Array) || !mailOptions.to.length)) {
|
|
954
|
+
throw new Error('[mail-time] `mailOptions.to` is required and must be a string or non-empty Array');
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (this.concatEmails) {
|
|
958
|
+
sendAt = sendAt + this.concatDelay;
|
|
959
|
+
const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
|
|
960
|
+
|
|
961
|
+
if (task) {
|
|
962
|
+
const pendingMailOptions = task.mailOptions || [];
|
|
963
|
+
|
|
964
|
+
for (let i = 0; i < pendingMailOptions.length; i++) {
|
|
965
|
+
if (equals(pendingMailOptions[i], mailOptions)) {
|
|
966
|
+
return task.uuid;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
pendingMailOptions.push(opts);
|
|
971
|
+
await this.queue.update(task, {
|
|
972
|
+
mailOptions: pendingMailOptions
|
|
973
|
+
});
|
|
974
|
+
return task.uuid;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return await this.___addToQueue({
|
|
979
|
+
sendAt,
|
|
980
|
+
template,
|
|
981
|
+
concatSubject,
|
|
982
|
+
mailOptions: mailOptions,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* @memberOf MailTime
|
|
988
|
+
* @name cancel
|
|
989
|
+
* @description alias of `cancelMail`
|
|
990
|
+
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
|
|
991
|
+
*/
|
|
992
|
+
async cancel(uuid) {
|
|
993
|
+
this._debug('[cancel]', uuid);
|
|
994
|
+
return await this.cancelMail(uuid);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* @async
|
|
999
|
+
* @memberOf MailTime
|
|
1000
|
+
* @name cancelMail
|
|
1001
|
+
* @description remove email from the queue or mark as `isCancelled`
|
|
1002
|
+
* @param uuid {string|Promise<string>} - uuid returned from `send` or `sendMail`
|
|
1003
|
+
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
|
|
1004
|
+
*/
|
|
1005
|
+
async cancelMail(uuid) {
|
|
1006
|
+
this._debug('[cancelMail]', uuid);
|
|
1007
|
+
if (typeof uuid === 'object' && uuid instanceof Promise) {
|
|
1008
|
+
return await this.queue.cancel(await uuid);
|
|
1009
|
+
}
|
|
1010
|
+
return await this.queue.cancel(uuid);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* @async
|
|
1015
|
+
* @memberOf MailTime
|
|
1016
|
+
* @name ___handleError
|
|
1017
|
+
* @description Handle runtime errors and pass to `onError` callback
|
|
1018
|
+
* @param task {object} - Email task record form Storage
|
|
1019
|
+
* @param error {mix} - Error String/Object/Error
|
|
1020
|
+
* @param info {object} - Info object returned from nodemailer
|
|
1021
|
+
* @returns {Promise<void 0>}
|
|
1022
|
+
*/
|
|
1023
|
+
async ___handleError(task, error, info) {
|
|
1024
|
+
this._debug('[private handleError]', { task, error, info });
|
|
1025
|
+
if (!task) {
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (task.tries >= this.maxTries) {
|
|
1030
|
+
task.isSent = false;
|
|
1031
|
+
task.isFailed = true;
|
|
1032
|
+
|
|
1033
|
+
if (!this.keepHistory) {
|
|
1034
|
+
await this.queue.remove(task);
|
|
1035
|
+
} else {
|
|
1036
|
+
await this.queue.update(task, {
|
|
1037
|
+
isSent: task.isSent,
|
|
1038
|
+
isFailed: task.isFailed,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
this._debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
|
|
1043
|
+
this.onError(error, task, info);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
let transportIndex = task.transport;
|
|
1048
|
+
|
|
1049
|
+
if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) {
|
|
1050
|
+
++transportIndex;
|
|
1051
|
+
if (transportIndex > this.transports.length - 1) {
|
|
1052
|
+
transportIndex = 0;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
await this.queue.update(task, {
|
|
1057
|
+
isSent: false,
|
|
1058
|
+
sendAt: Date.now() + this.retryDelay,
|
|
1059
|
+
transport: transportIndex,
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
this._debug(`[private handleError] Next re-send attempt at ${new Date(Date.now() + this.retryDelay)}: #${task.tries}/${this.maxTries}, transport #${transportIndex} to: `, task.mailOptions[0].to, error);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* @async
|
|
1067
|
+
* @memberOf MailTime
|
|
1068
|
+
* @name ___addToQueue
|
|
1069
|
+
* @description Prepare task's object and push to the queue
|
|
1070
|
+
* @param opts {object} - Email options with next properties:
|
|
1071
|
+
* @param opts.sendAt {number} - When email should be sent
|
|
1072
|
+
* @param opts.template {string} - Email-specific template
|
|
1073
|
+
* @param opts.mailOptions {object} - MailOptions according to NodeMailer lib
|
|
1074
|
+
* @param opts.concatSubject {string} - Email subject used when sending concatenated email
|
|
1075
|
+
* @returns {Promise<string>} message uuid
|
|
1076
|
+
*/
|
|
1077
|
+
async ___addToQueue(opts) {
|
|
1078
|
+
this._debug('[private addToQueue]', opts);
|
|
1079
|
+
const task = {
|
|
1080
|
+
uuid: crypto.randomUUID(),
|
|
1081
|
+
tries: 0,
|
|
1082
|
+
isSent: false,
|
|
1083
|
+
sendAt: opts.sendAt,
|
|
1084
|
+
isFailed: false,
|
|
1085
|
+
template: opts.template,
|
|
1086
|
+
transport: this.transport,
|
|
1087
|
+
isCancelled: false,
|
|
1088
|
+
mailOptions: [opts.mailOptions],
|
|
1089
|
+
concatSubject: opts.concatSubject,
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
if (this.concatEmails) {
|
|
1093
|
+
task.to = opts.mailOptions.to;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
await this.queue.push(task);
|
|
1097
|
+
return task.uuid;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* @memberOf MailTime
|
|
1102
|
+
* @name ___render
|
|
1103
|
+
* @description Render templates
|
|
1104
|
+
* @param string {string} - Template with Mustache-like placeholders
|
|
1105
|
+
* @param replacements {object} - Blaze/Mustache-like helpers Object
|
|
1106
|
+
* @returns {string}
|
|
1107
|
+
*/
|
|
1108
|
+
___render(_string, replacements) {
|
|
1109
|
+
let i;
|
|
1110
|
+
let string = _string;
|
|
1111
|
+
const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-\_]+)\s?\}{3}/g);
|
|
1112
|
+
if (matchHTML) {
|
|
1113
|
+
for (i = 0; i < matchHTML.length; i++) {
|
|
1114
|
+
if (replacements[matchHTML[i].replace('{{{', '').replace('}}}', '').trim()]) {
|
|
1115
|
+
string = string.replace(matchHTML[i], replacements[matchHTML[i].replace('{{{', '').replace('}}}', '').trim()]);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9\-\_]+)\s?\}{2}/g);
|
|
1121
|
+
if (matchStr) {
|
|
1122
|
+
for (i = 0; i < matchStr.length; i++) {
|
|
1123
|
+
if (replacements[matchStr[i].replace('{{', '').replace('}}', '').trim()]) {
|
|
1124
|
+
string = string.replace(matchStr[i], replacements[matchStr[i].replace('{{', '').replace('}}', '').trim()].replace(/<(?:.|\n)*?>/gm, ''));
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return string;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* @memberOf MailTime
|
|
1133
|
+
* @name ___compileMailOpts
|
|
1134
|
+
* @description Run various checks, compile options, and render template
|
|
1135
|
+
* @param transport {object} - Current transport
|
|
1136
|
+
* @param info {object} - Info object returned from NodeMailer's `sendMail` method
|
|
1137
|
+
* @returns {void 0}
|
|
1138
|
+
*/
|
|
1139
|
+
___compileMailOpts(transport, task) {
|
|
1140
|
+
let compiledOpts = {};
|
|
1141
|
+
|
|
1142
|
+
if (!transport) {
|
|
1143
|
+
throw new Error('[mail-time] [sendMail] [___compileMailOpts] {transport} is not available or misconfigured!');
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (transport._options && typeof transport._options === 'object' && transport._options !== null && transport._options.mailOptions) {
|
|
1147
|
+
compiledOpts = merge(compiledOpts, transport._options.mailOptions);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (transport.options && typeof transport.options === 'object' && transport.options !== null && transport.options.mailOptions) {
|
|
1151
|
+
compiledOpts = merge(compiledOpts, transport.options.mailOptions);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
compiledOpts = merge(compiledOpts, {
|
|
1155
|
+
html: '',
|
|
1156
|
+
text: '',
|
|
1157
|
+
subject: ''
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
for (let i = 0; i < task.mailOptions.length; i++) {
|
|
1161
|
+
if (task.mailOptions[i].html) {
|
|
1162
|
+
if (task.mailOptions.length > 1) {
|
|
1163
|
+
compiledOpts.html += this.___render(this.concatDelimiter, task.mailOptions[i]) + this.___render(task.mailOptions[i].html, task.mailOptions[i]);
|
|
1164
|
+
} else {
|
|
1165
|
+
compiledOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]);
|
|
1166
|
+
}
|
|
1167
|
+
delete task.mailOptions[i].html;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (task.mailOptions[i].text) {
|
|
1171
|
+
if (task.mailOptions.length > 1) {
|
|
1172
|
+
compiledOpts.text += '\r\n' + this.___render(task.mailOptions[i].text, task.mailOptions[i]);
|
|
1173
|
+
} else {
|
|
1174
|
+
compiledOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]);
|
|
1175
|
+
}
|
|
1176
|
+
delete task.mailOptions[i].text;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
compiledOpts = merge(compiledOpts, task.mailOptions[i]);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (compiledOpts.html && (task.template || this.template)) {
|
|
1183
|
+
compiledOpts.html = this.___render((task.template || this.template), compiledOpts);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (task.mailOptions.length > 1) {
|
|
1187
|
+
compiledOpts.subject = task.concatSubject || this.concatSubject || compiledOpts.subject;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (!compiledOpts.from && this.from) {
|
|
1191
|
+
compiledOpts.from = this.from(transport);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return compiledOpts;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* @async
|
|
1199
|
+
* @memberOf MailTime
|
|
1200
|
+
* @name ___send
|
|
1201
|
+
* @description send email using nodemailer's transport
|
|
1202
|
+
* @param task {object} - email's task object from Storage
|
|
1203
|
+
* @returns {Promise<void 0>}
|
|
1204
|
+
*/
|
|
1205
|
+
async ___send(task) {
|
|
1206
|
+
this._debug('[private send]', task);
|
|
1207
|
+
try {
|
|
1208
|
+
if (task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
task.tries++;
|
|
1213
|
+
const isUpdated = await this.queue.update(task, {
|
|
1214
|
+
isSent: true,
|
|
1215
|
+
tries: task.tries
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
if (!isUpdated) {
|
|
1219
|
+
logError('[private send] [queue.update] Not updated!');
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
let transport;
|
|
1224
|
+
let transportIndex;
|
|
1225
|
+
if (this.strategy === 'balancer') {
|
|
1226
|
+
this.transport = this.transport + 1;
|
|
1227
|
+
if (this.transport >= this.transports.length) {
|
|
1228
|
+
this.transport = 0;
|
|
1229
|
+
}
|
|
1230
|
+
transportIndex = this.transport;
|
|
1231
|
+
transport = this.transports[transportIndex];
|
|
1232
|
+
} else {
|
|
1233
|
+
transportIndex = task.transport;
|
|
1234
|
+
transport = this.transports[transportIndex];
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const compiledOpts = this.___compileMailOpts(transport, task);
|
|
1238
|
+
|
|
1239
|
+
await new Promise((resolve) => {
|
|
1240
|
+
transport.sendMail(compiledOpts, async (error, info) => {
|
|
1241
|
+
this._debug('[private send] [sending]', { error, info });
|
|
1242
|
+
if (error) {
|
|
1243
|
+
await this.___handleError(task, error, info);
|
|
1244
|
+
resolve();
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (info.accepted && !info.accepted.length) {
|
|
1249
|
+
await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
|
|
1250
|
+
resolve();
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
this._debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
1255
|
+
|
|
1256
|
+
if (!this.keepHistory) {
|
|
1257
|
+
await this.queue.remove(task);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
task.isSent = true;
|
|
1261
|
+
this.onSent(task, info);
|
|
1262
|
+
resolve();
|
|
1263
|
+
});
|
|
1264
|
+
});
|
|
1265
|
+
} catch (e) {
|
|
1266
|
+
logError('Exception during runtime:', e);
|
|
1267
|
+
this.___handleError(task, e, {});
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* @memberOf MailTime
|
|
1273
|
+
* @name ___iterate
|
|
1274
|
+
* @description Iterate over queued tasks
|
|
1275
|
+
* @returns {Promise}
|
|
1276
|
+
*/
|
|
1277
|
+
async ___iterate() {
|
|
1278
|
+
this._debug('[private iterate]');
|
|
1279
|
+
return await this.queue.iterate();
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
exports.MailTime = MailTime;
|
|
1284
|
+
exports.MongoQueue = MongoQueue;
|
|
1285
|
+
exports.RedisQueue = RedisQueue;
|