mail-time 1.4.0 → 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/index.cjs CHANGED
@@ -1,23 +1,17 @@
1
1
  'use strict';
2
2
 
3
- const JoSk = require('josk');
3
+ const josk = require('josk');
4
4
  const merge = require('deepmerge');
5
+ const crypto = require('crypto');
5
6
 
6
- const noop = () => {};
7
- const _debug = (isDebug, ...args) => {
7
+ const debug = (isDebug, ...args) => {
8
8
  if (isDebug) {
9
9
  console.info.call(console, '[DEBUG] [mail-time]', `${new Date}`, ...args);
10
10
  }
11
11
  };
12
- const _logError = (...args) => {
13
- console.error.call(console, '[ERROR] [mail-time]', `${new Date}`, ...args);
14
- };
15
12
 
16
- const mongoErrorHandler = (error) => {
17
- if (error) {
18
- _logError('[mongoErrorHandler]:', error);
19
- console.trace();
20
- }
13
+ const logError = (...args) => {
14
+ console.error.call(console, '[ERROR] [mail-time]', `${new Date}`, ...args);
21
15
  };
22
16
 
23
17
  /**
@@ -62,11 +56,572 @@ const ensureIndex = async (collection, keys, opts) => {
62
56
  await collection.createIndex(keys, opts);
63
57
  }
64
58
  } else {
65
- _logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection._name}" collection`, { keys, opts, details: e });
59
+ logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection._name || 'MongoDB'}" collection`, { keys, opts, details: e });
66
60
  }
67
61
  }
68
62
  };
69
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
+
70
625
  /**
71
626
  * Check if entities of various types are equal
72
627
  * including edge cases like unordered Objects and Array
@@ -151,38 +706,80 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
151
706
 
152
707
  /** Class of MailTime */
153
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
+ */
154
730
  constructor (opts) {
155
731
  if (!opts || typeof opts !== 'object' || opts === null) {
156
732
  throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor');
157
733
  }
158
734
 
159
- if (!opts.db) {
160
- throw new Error('[mail-time] MongoDB database {db} option is required, like returned from `MongoClient.connect`');
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
+ }
161
749
  }
162
750
 
163
- this.callbacks = {};
164
- this.type = (!opts.type || (opts.type !== 'client' && opts.type !== 'server')) ? 'server' : opts.type;
165
751
  this.debug = (opts.debug !== true) ? false : true;
166
- this.prefix = opts.prefix || '';
167
- this.maxTries = ((opts.maxTries && !isNaN(opts.maxTries)) ? parseInt(opts.maxTries) : 59) + 1;
168
- this.interval = ((opts.interval && !isNaN(opts.interval)) ? parseInt(opts.interval) : 60) * 1000;
169
- this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
170
- this.zombieTime = opts.zombieTime || 32786;
171
- this.keepHistory = opts.keepHistory || false;
752
+ this._debug = (...args) => {
753
+ debug(this.debug, ...args);
754
+ };
172
755
 
173
- this.revolvingInterval = opts.revolvingInterval || 1536;
174
- this.minRevolvingDelay = opts.minRevolvingDelay || 512;
175
- this.maxRevolvingDelay = opts.maxRevolvingDelay || 2048;
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 : '';
176
758
 
177
- if (this.interval < 2048 || isNaN(this.interval)) {
178
- this.interval = 3072;
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;
179
765
  }
180
766
 
181
- if (this.zombieTime < 8192 || isNaN(this.zombieTime)) {
182
- this.zombieTime = 8192;
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;
183
773
  }
184
774
 
185
- this.failsToNext = (opts.failsToNext && !isNaN(opts.failsToNext)) ? parseInt(opts.failsToNext) : 4;
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;
186
783
  this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup';
187
784
  this.transports = opts.transports || [];
188
785
  this.transport = 0;
@@ -203,348 +800,247 @@ class MailTime {
203
800
  this.concatEmails = (opts.concatEmails !== true) ? false : true;
204
801
  this.concatSubject = (opts.concatSubject && typeof opts.concatSubject === 'string') ? opts.concatSubject : 'Multiple notifications';
205
802
  this.concatDelimiter = (opts.concatDelimiter && typeof opts.concatDelimiter === 'string') ? opts.concatDelimiter : '<hr>';
206
- this.concatThrottling = ((opts.concatThrottling && !isNaN(opts.concatThrottling)) ? parseInt(opts.concatThrottling) : 60) * 1000;
207
803
 
208
- if (this.concatThrottling < 2048) {
209
- this.concatThrottling = 3072;
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`');
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;
221
810
  }
222
811
 
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...
244
-
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 */
245
823
  if (this.type === 'server') {
246
- const scheduler = new JoSk({
247
- db: opts.db,
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
- });
255
-
256
- scheduler.setInterval(this.___send.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
257
- }
258
- }
259
-
260
- static get Template() {
261
- return DEFAULT_TEMPLATE;
262
- }
263
-
264
- static set Template(newVal) {
265
- DEFAULT_TEMPLATE = newVal;
266
- }
267
-
268
- ___compileMailOpts(transport, task) {
269
- let _mailOpts = {};
270
-
271
- if (!transport) {
272
- throw new Error('[mail-time] [sendMail] [___compileMailOpts] transport not available or misconfiguration is in place!');
273
- }
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
+ }
274
827
 
275
- if (transport._options && typeof transport._options === 'object' && transport._options !== null && transport._options.mailOptions) {
276
- _mailOpts = merge(_mailOpts, transport._options.mailOptions);
277
- }
828
+ if (typeof opts.josk !== 'object') {
829
+ throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}');
830
+ }
278
831
 
279
- if (transport.options && typeof transport.options === 'object' && transport.options !== null && transport.options.mailOptions) {
280
- _mailOpts = merge(_mailOpts, transport.options.mailOptions);
281
- }
832
+ if (typeof opts.josk.adapter !== 'object') {
833
+ throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class');
834
+ }
282
835
 
283
- _mailOpts = merge(_mailOpts, {
284
- html: '',
285
- text: '',
286
- subject: ''
287
- });
836
+ this.josk = { ...opts.josk };
288
837
 
289
- for (let i = 0; i < task.mailOptions.length; i++) {
290
- if (task.mailOptions[i].html) {
291
- if (task.mailOptions.length > 1) {
292
- _mailOpts.html += this.___render(this.concatDelimiter, task.mailOptions[i]) + this.___render(task.mailOptions[i].html, task.mailOptions[i]);
293
- } else {
294
- _mailOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]);
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"}');
295
841
  }
296
- delete task.mailOptions[i].html;
842
+ this.josk.adapter = new josk.MongoAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
297
843
  }
298
844
 
299
- if (task.mailOptions[i].text) {
300
- if (task.mailOptions.length > 1) {
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]);
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"}');
304
848
  }
305
- delete task.mailOptions[i].text;
849
+ this.josk.adapter = new josk.RedisAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
306
850
  }
307
851
 
308
- _mailOpts = merge(_mailOpts, task.mailOptions[i]);
309
- }
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;
310
855
 
311
- if (_mailOpts.html && (task.template || this.template)) {
312
- _mailOpts.html = this.___render((task.template || this.template), _mailOpts);
313
- }
856
+ this.scheduler = new josk.JoSk({
857
+ debug: this.debug,
858
+ ...this.josk,
859
+ });
314
860
 
315
- if (task.mailOptions.length > 1) {
316
- _mailOpts.subject = task.concatSubject || this.concatSubject || _mailOpts.subject;
317
- }
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
+ });
318
866
 
319
- if (!_mailOpts.from && this.from) {
320
- _mailOpts.from = this.from(transport);
867
+ this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
321
868
  }
869
+ }
870
+
871
+ static get Template() {
872
+ return DEFAULT_TEMPLATE;
873
+ }
322
874
 
323
- return _mailOpts;
875
+ static set Template(newVal) {
876
+ DEFAULT_TEMPLATE = newVal;
324
877
  }
325
878
 
326
879
  /**
880
+ * @async
327
881
  * @memberOf MailTime
328
- * @name ___send
329
- * @param ready {Function} - See JoSk NPM package
330
- * @returns {void}
882
+ * @name ping
883
+ * @description Check package readiness and connection to Storage
884
+ * @returns {Promise<object>}
885
+ * @throws {mix}
331
886
  */
332
- ___send(ready) {
333
- const cursor = this.collection.find({
334
- isSent: false,
335
- sendAt: {
336
- $lte: new Date()
337
- },
338
- tries: {
339
- $lt: this.maxTries
340
- }
341
- }, {
342
- projection: {
343
- _id: 1,
344
- tries: 1,
345
- template: 1,
346
- transport: 1,
347
- mailOptions: 1,
348
- concatSubject: 1
349
- }
350
- });
351
-
352
- cursor.forEach(async (task) => {
353
- try {
354
- await this.collection.updateOne({
355
- _id: task._id
356
- }, {
357
- $set: {
358
- isSent: true
359
- },
360
- $inc: {
361
- tries: 1
362
- }
363
- });
364
-
365
- let transport;
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
- }
378
-
379
- const _mailOpts = this.___compileMailOpts(transport, task);
380
-
381
- _debug(this.debug, '[sendMail] [sending] To:', _mailOpts.to);
382
- transport.sendMail(_mailOpts, (error, info) => {
383
- if (error) {
384
- this.___handleError(task, error, info);
385
- return;
386
- }
387
-
388
- if (info.accepted && !info.accepted.length) {
389
- this.___handleError(task, 'Message not accepted or Greeting never received', info);
390
- return;
391
- }
392
-
393
- _debug(this.debug, `email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, _mailOpts.to);
394
-
395
- if (!this.keepHistory) {
396
- this.collection.deleteOne({
397
- _id: task._id
398
- }).catch(mongoErrorHandler);
399
- }
400
-
401
- this.___triggerCallbacks(void 0, task, info);
402
- return;
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();
413
- });
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();
414
894
  }
415
895
 
416
896
  /**
417
897
  * @memberOf MailTime
418
898
  * @name send
419
899
  * @description alias of `sendMail`
420
- * @returns {void}
900
+ * @returns {Promise<string>} uuid of the email
421
901
  */
422
- send(opts, callback) {
423
- this.sendMail(opts, callback);
902
+ async send(opts) {
903
+ this._debug('[send]', opts);
904
+ return await this.sendMail(opts);
424
905
  }
425
906
 
426
907
  /**
908
+ * @async
427
909
  * @memberOf MailTime
428
910
  * @name sendMail
429
- * @param opts {Object} - Letter options with next properties:
430
- * @param opts.sendAt {Date} - When email should be sent
431
- * @param opts.template {String} - Template string
432
- * @param opts[key] {mix} - Other MailOptions according to NodeMailer lib documentation
433
- * @param callback {Function} - [OPTIONAL] Callback function
434
- * @returns {void}
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}
435
918
  */
436
- sendMail(opts = {}, callback = noop) {
437
- _debug(this.debug, '[sendMail] [attempt] To:', opts.to);
919
+ async sendMail(opts = {}) {
920
+ this._debug('[sendMail]', opts);
438
921
  if (!opts.html && !opts.text) {
439
922
  throw new Error('`html` nor `text` field is presented, at least one of those fields is required');
440
923
  }
441
924
 
442
- if (!opts.sendAt || Object.prototype.toString.call(opts.sendAt) !== '[object Date]') {
443
- opts.sendAt = new Date();
925
+ let sendAt = opts.sendAt;
926
+ if (!sendAt) {
927
+ sendAt = Date.now();
444
928
  }
445
929
 
446
- if (typeof opts.template !== 'string') {
447
- opts.template = false;
930
+ if (sendAt instanceof Date) {
931
+ sendAt = +sendAt;
448
932
  }
449
933
 
450
- if (typeof opts.concatSubject !== 'string') {
451
- opts.concatSubject = false;
934
+ if (typeof sendAt !== 'number') {
935
+ sendAt = Date.now();
452
936
  }
453
937
 
454
- let _sendAt = opts.sendAt;
455
- const _template = opts.template;
456
- const _concatSubject = opts.concatSubject;
457
- delete opts.sendAt;
458
- delete opts.template;
459
- delete opts.concatSubject;
938
+ let template = opts.template;
939
+ if (typeof template !== 'string') {
940
+ template = false;
941
+ }
460
942
 
461
- if (typeof opts.to !== 'string' && (!(opts.to instanceof Array) || !opts.to.length)) {
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)) {
462
954
  throw new Error('[mail-time] `mailOptions.to` is required and must be a string or non-empty Array');
463
955
  }
464
956
 
465
957
  if (this.concatEmails) {
466
- _sendAt = new Date(+_sendAt + this.concatThrottling);
467
- this.collection.findOne({
468
- to: opts.to,
469
- isSent: false,
470
- sendAt: {
471
- $lte: _sendAt
472
- }
473
- }, {
474
- projection: {
475
- _id: 1,
476
- mailOptions: 1
477
- }
478
- }).then((task) => {
479
- if (task) {
480
- const queue = task.mailOptions || [];
481
-
482
- for (let i = 0; i < queue.length; i++) {
483
- if (equals(queue[i], opts)) {
484
- return;
485
- }
486
- }
958
+ sendAt = sendAt + this.concatDelay;
959
+ const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
487
960
 
488
- queue.push(opts);
489
- this.collection.updateOne({
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
- }
961
+ if (task) {
962
+ const pendingMailOptions = task.mailOptions || [];
504
963
 
505
- this.___addToQueue({
506
- sendAt: _sendAt,
507
- template: _template,
508
- mailOptions: opts,
509
- concatSubject: _concatSubject
510
- }, callback);
511
- }).catch(mongoErrorHandler);
964
+ for (let i = 0; i < pendingMailOptions.length; i++) {
965
+ if (equals(pendingMailOptions[i], mailOptions)) {
966
+ return task.uuid;
967
+ }
968
+ }
512
969
 
513
- return;
970
+ pendingMailOptions.push(opts);
971
+ await this.queue.update(task, {
972
+ mailOptions: pendingMailOptions
973
+ });
974
+ return task.uuid;
975
+ }
514
976
  }
515
977
 
516
- this.___addToQueue({
517
- sendAt: _sendAt,
518
- template: _template,
519
- mailOptions: opts,
520
- concatSubject: _concatSubject
521
- }, callback);
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
+ }
522
996
 
523
- return;
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);
524
1011
  }
525
1012
 
526
1013
  /**
1014
+ * @async
527
1015
  * @memberOf MailTime
528
1016
  * @name ___handleError
529
- * @param task {Object} - Email task record form Mongo
530
- * @param error {mix} - Error String/Object/Error
531
- * @param info {Object} - Info object returned from nodemailer
532
- * @returns {void}
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>}
533
1022
  */
534
- ___handleError(task, error, info) {
1023
+ async ___handleError(task, error, info) {
1024
+ this._debug('[private handleError]', { task, error, info });
535
1025
  if (!task) {
536
1026
  return;
537
1027
  }
538
1028
 
539
1029
  if (task.tries >= this.maxTries) {
1030
+ task.isSent = false;
1031
+ task.isFailed = true;
1032
+
540
1033
  if (!this.keepHistory) {
541
- this.collection.deleteOne({
542
- _id: task._id
543
- }).catch(mongoErrorHandler);
1034
+ await this.queue.remove(task);
1035
+ } else {
1036
+ await this.queue.update(task, {
1037
+ isSent: task.isSent,
1038
+ isFailed: task.isFailed,
1039
+ });
544
1040
  }
545
1041
 
546
- _debug(this.debug, `Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
547
- this.___triggerCallbacks(error, task, info);
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);
548
1044
  return;
549
1045
  }
550
1046
 
@@ -557,61 +1053,57 @@ class MailTime {
557
1053
  }
558
1054
  }
559
1055
 
560
- this.collection.updateOne({
561
- _id: task._id
562
- }, {
563
- $set: {
564
- isSent: false,
565
- sendAt: new Date(Date.now() + this.interval),
566
- transport: transportIndex
567
- }
568
- }).catch(mongoErrorHandler);
1056
+ await this.queue.update(task, {
1057
+ isSent: false,
1058
+ sendAt: Date.now() + this.retryDelay,
1059
+ transport: transportIndex,
1060
+ });
569
1061
 
570
- _debug(this.debug, `Next re-send attempt at ${new Date(Date.now() + this.interval)}: #${task.tries}/${this.maxTries}, transport #${transportIndex} to: `, task.mailOptions[0].to, error);
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);
571
1063
  }
572
1064
 
573
1065
  /**
1066
+ * @async
574
1067
  * @memberOf MailTime
575
1068
  * @name ___addToQueue
576
- * @param opts {Object} - Letter options with next properties:
577
- * @param opts.sendAt {Date} - When email should be sent
578
- * @param opts.template {String} - Email template
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}
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
583
1076
  */
584
- ___addToQueue(opts, callback) {
585
- _debug(this.debug, '[sendMail] [adding to queue] To:', opts.mailOptions.to);
1077
+ async ___addToQueue(opts) {
1078
+ this._debug('[private addToQueue]', opts);
586
1079
  const task = {
1080
+ uuid: crypto.randomUUID(),
587
1081
  tries: 0,
588
1082
  isSent: false,
589
1083
  sendAt: opts.sendAt,
1084
+ isFailed: false,
590
1085
  template: opts.template,
591
1086
  transport: this.transport,
1087
+ isCancelled: false,
592
1088
  mailOptions: [opts.mailOptions],
593
- concatSubject: opts.concatSubject
1089
+ concatSubject: opts.concatSubject,
594
1090
  };
595
1091
 
596
1092
  if (this.concatEmails) {
597
1093
  task.to = opts.mailOptions.to;
598
1094
  }
599
1095
 
600
- this.collection.insertOne(task).then((r) => {
601
- const _id = r.insertedId.toHexString();
602
- if (!this.callbacks[_id]) {
603
- this.callbacks[_id] = [];
604
- }
605
- this.callbacks[_id].push(callback);
606
- }).catch(mongoErrorHandler);
1096
+ await this.queue.push(task);
1097
+ return task.uuid;
607
1098
  }
608
1099
 
609
1100
  /**
610
1101
  * @memberOf MailTime
611
1102
  * @name ___render
612
- * @param string {String} - Template with Spacebars/Blaze/Mustache-like placeholders
613
- * @param replacements {Object} - Blaze/Mustache-like helpers Object
614
- * @returns {String}
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}
615
1107
  */
616
1108
  ___render(_string, replacements) {
617
1109
  let i;
@@ -638,21 +1130,156 @@ class MailTime {
638
1130
 
639
1131
  /**
640
1132
  * @memberOf MailTime
641
- * @name ___triggerCallbacks
642
- * @param error {mix} - Error thrown during sending email
643
- * @param task {Object} - Task record from mongodb
644
- * @param info {Object} - Info object returned from NodeMailer's `sendMail` method
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
645
1137
  * @returns {void 0}
646
1138
  */
647
- ___triggerCallbacks(error, task, info) {
648
- const _id = task._id.toHexString();
649
- if (this.callbacks[_id] && this.callbacks[_id].length) {
650
- this.callbacks[_id].forEach((cb, i) => {
651
- cb(error, info, task?.mailOptions?.[i]);
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
652
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, {});
653
1268
  }
654
- delete this.callbacks[_id];
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();
655
1280
  }
656
1281
  }
657
1282
 
658
- module.exports = MailTime;
1283
+ exports.MailTime = MailTime;
1284
+ exports.MongoQueue = MongoQueue;
1285
+ exports.RedisQueue = RedisQueue;