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.js CHANGED
@@ -1,69 +1,12 @@
1
- import JoSk from 'josk';
1
+ import { JoSk, RedisAdapter, MongoAdapter } from 'josk';
2
2
  import merge from 'deepmerge';
3
3
 
4
- const noop = () => {};
5
- const _debug = (isDebug, ...args) => {
6
- if (isDebug) {
7
- console.info.call(console, '[DEBUG] [mail-time]', `${new Date}`, ...args);
8
- }
9
- };
10
- const _logError = (...args) => {
11
- console.error.call(console, '[ERROR] [mail-time]', `${new Date}`, ...args);
12
- };
4
+ import crypto from 'crypto';
5
+ import { MongoQueue } from './adapters/mongo.js';
6
+ import { RedisQueue } from './adapters/redis.js';
7
+ import { debug, logError } from './helpers.js';
13
8
 
14
- const mongoErrorHandler = (error) => {
15
- if (error) {
16
- _logError('[mongoErrorHandler]:', error);
17
- console.trace();
18
- }
19
- };
20
-
21
- /**
22
- * Ensure (create) index on MongoDB collection, catch and log exception if thrown
23
- * @function ensureIndex
24
- * @param {Collection} collection - Mongo's driver Collection instance
25
- * @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
26
- * @param {object} opts - Set of options that controls the creation of the index
27
- * @returns {void 0}
28
- */
29
- const ensureIndex = async (collection, keys, opts) => {
30
- try {
31
- await collection.createIndex(keys, opts);
32
- } catch (e) {
33
- if (e.code === 85) {
34
- let indexName;
35
- const indexes = await collection.indexes();
36
- for (const index of indexes) {
37
- let drop = true;
38
- for (const indexKey of Object.keys(keys)) {
39
- if (typeof index.key[indexKey] === 'undefined') {
40
- drop = false;
41
- break;
42
- }
43
- }
44
-
45
- for (const indexKey of Object.keys(index.key)) {
46
- if (typeof keys[indexKey] === 'undefined') {
47
- drop = false;
48
- break;
49
- }
50
- }
51
-
52
- if (drop) {
53
- indexName = index.name;
54
- break;
55
- }
56
- }
57
-
58
- if (indexName) {
59
- await collection.dropIndex(indexName);
60
- await collection.createIndex(keys, opts);
61
- }
62
- } else {
63
- _logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection._name}" collection`, { keys, opts, details: e });
64
- }
65
- }
66
- };
9
+ const noop = () => {};
67
10
 
68
11
  /**
69
12
  * Check if entities of various types are equal
@@ -148,39 +91,81 @@ const equals = (a, b) => {
148
91
  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>';
149
92
 
150
93
  /** Class of MailTime */
151
- export default class MailTime {
94
+ class MailTime {
95
+ /**
96
+ * Create a MailTime instance
97
+ * @param {object} opts - configuration object
98
+ * @param {RedisQueue|MongoQueue|CustomQueue} opts.queue - Queue Storage Driver instance
99
+ * @param {string} [opts.type] - "server" or "client" type of MailTime instance
100
+ * @param {function} [opts.from] - A function returning *String* for `from` header, format: `"MyApp" <user@example.com>`
101
+ * @param {[object]} [opts.transports] - An array of `nodemailer`'s transports, returned from `nodemailer.createTransport({})`; Required for `{type: 'server'}`
102
+ * @param {string} [opts.strategy] - `backup` or `balancer`
103
+ * @param {number} [opts.failsToNext] - After how many failed "send attempts" switch to the next transport? Applied only for `backup` strategy, default - `4`
104
+ * @param {number} [opts.retries] - How many times resend failed emails
105
+ * @param {number} [opts.retryDelay] - Interval in milliseconds between send re-tries
106
+ * @param {boolean} [opts.keepHistory] - Keep queue task as it is in the database
107
+ * @param {boolean} [opts.concatEmails] - Concatenate email by `to` field, default - `false`
108
+ * @param {string} [opts.concatSubject] - Email subject used in concatenated email, default - `Multiple notifications`
109
+ * @param {string} [opts.concatDelimiter] - HTML or plain string delimiter used between concatenated email, default - `<hr>`
110
+ * @param {number} [opts.concatDelay] - HTML or plain string delimiter used between concatenated email, default - `<hr>`
111
+ * @param {number} [opts.revolvingInterval] - Interval in *milliseconds* in between queue checks, default - `256`
112
+ * @param {object|RedisAdapter|MongoAdapter|CustomAdapter} [opts.josk.adapter] - Interval in milliseconds between send re-tries
113
+ * @param {string} [opts.josk.adapter.type] - One of `mongo` or `redis`
114
+ * @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
115
+ */
152
116
  constructor (opts) {
153
117
  if (!opts || typeof opts !== 'object' || opts === null) {
154
118
  throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor');
155
119
  }
156
120
 
157
- if (!opts.db) {
158
- throw new Error('[mail-time] MongoDB database {db} option is required, like returned from `MongoClient.connect`');
121
+ if (!opts.queue || typeof opts.queue !== 'object') {
122
+ throw new Error('[mail-time] {queue} option is required', {
123
+ description: 'MailTime requires MongoQueue, RedisQueue, or CustomQueue to connect to an intermediate database'
124
+ });
125
+ }
126
+
127
+ this.queue = opts.queue;
128
+ this.queue.mailTimeInstance = this;
129
+ const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
130
+
131
+ for (let i = queueMethods.length - 1; i >= 0; i--) {
132
+ if (typeof this.queue[queueMethods[i]] !== 'function') {
133
+ throw new Error(`{queue} instance is missing {${queueMethods[i]}} method that is required!`);
134
+ }
159
135
  }
160
136
 
161
- this.callbacks = {};
162
- this.type = (!opts.type || (opts.type !== 'client' && opts.type !== 'server')) ? 'server' : opts.type;
163
137
  this.debug = (opts.debug !== true) ? false : true;
164
- this.prefix = opts.prefix || '';
165
- this.maxTries = ((opts.maxTries && !isNaN(opts.maxTries)) ? parseInt(opts.maxTries) : 59) + 1;
166
- this.interval = ((opts.interval && !isNaN(opts.interval)) ? parseInt(opts.interval) : 60) * 1000;
167
- this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
168
- this.zombieTime = opts.zombieTime || 32786;
169
- this.keepHistory = opts.keepHistory || false;
138
+ this._debug = (...args) => {
139
+ debug(this.debug, ...args);
140
+ };
170
141
 
171
- this.revolvingInterval = opts.revolvingInterval || 1536;
172
- this.minRevolvingDelay = opts.minRevolvingDelay || 512;
173
- this.maxRevolvingDelay = opts.maxRevolvingDelay || 2048;
142
+ this.type = (typeof opts.type !== 'string' || (opts.type !== 'client' && opts.type !== 'server')) ? 'server' : opts.type;
143
+ this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '';
174
144
 
175
- if (this.interval < 2048 || isNaN(this.interval)) {
176
- this.interval = 3072;
145
+ if (typeof opts.retries === 'number') {
146
+ this.maxTries = opts.retries + 1;
147
+ } else if (typeof opts.maxTries === 'number') {
148
+ this.maxTries = (opts.maxTries < 1) ? 1 : 0;
149
+ } else {
150
+ this.maxTries = 60;
177
151
  }
178
152
 
179
- if (this.zombieTime < 8192 || isNaN(this.zombieTime)) {
180
- this.zombieTime = 8192;
153
+ if (typeof opts.retryDelay === 'number') {
154
+ this.retryDelay = opts.retryDelay;
155
+ } else if (typeof opts.interval === 'number') {
156
+ this.retryDelay = opts.interval * 1000;
157
+ } else {
158
+ this.retryDelay = 60000;
181
159
  }
182
160
 
183
- this.failsToNext = (opts.failsToNext && !isNaN(opts.failsToNext)) ? parseInt(opts.failsToNext) : 4;
161
+ this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
162
+ this.keepHistory = (typeof opts.keepHistory === 'boolean') ? opts.keepHistory : false;
163
+ this.onSent = opts.onSent || noop;
164
+ this.onError = opts.onError || noop;
165
+
166
+ this.revolvingInterval = opts.revolvingInterval || 1536;
167
+
168
+ this.failsToNext = (typeof opts.failsToNext === 'number') ? opts.failsToNext : 4;
184
169
  this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup';
185
170
  this.transports = opts.transports || [];
186
171
  this.transport = 0;
@@ -201,348 +186,247 @@ export default class MailTime {
201
186
  this.concatEmails = (opts.concatEmails !== true) ? false : true;
202
187
  this.concatSubject = (opts.concatSubject && typeof opts.concatSubject === 'string') ? opts.concatSubject : 'Multiple notifications';
203
188
  this.concatDelimiter = (opts.concatDelimiter && typeof opts.concatDelimiter === 'string') ? opts.concatDelimiter : '<hr>';
204
- this.concatThrottling = ((opts.concatThrottling && !isNaN(opts.concatThrottling)) ? parseInt(opts.concatThrottling) : 60) * 1000;
205
-
206
- if (this.concatThrottling < 2048) {
207
- this.concatThrottling = 3072;
208
- }
209
-
210
- _debug(this.debug, 'DEBUG ON {debug: true}');
211
- _debug(this.debug, `INITIALIZING [type: ${this.type}]`);
212
- _debug(this.debug, `INITIALIZING [strategy: ${this.strategy}]`);
213
- _debug(this.debug, `INITIALIZING [prefix: ${this.prefix}]`);
214
- _debug(this.debug, `INITIALIZING [maxTries: ${this.maxTries}]`);
215
- _debug(this.debug, `INITIALIZING [failsToNext: ${this.failsToNext}]`);
216
-
217
- if (opts.type === 'server' && (this.transports.constructor !== Array || !this.transports.length)) {
218
- throw new Error('[mail-time] transports is required and must be an Array, like returned from `nodemailer.createTransport`');
219
- }
220
-
221
- this.collection = opts.db.collection(`__mailTimeQueue__${this.prefix}`);
222
- ensureIndex(this.collection, { isSent: 1, to: 1, sendAt: 1 });
223
- ensureIndex(this.collection, { isSent: 1, sendAt: 1, tries: 1 }, { background: true });
224
-
225
- // MongoDB Collection Schema:
226
- // _id
227
- // to {String|[String]}
228
- // tries {Number} - qty of send attempts
229
- // sendAt {Date} - When letter should be sent
230
- // isSent {Boolean} - Email status
231
- // template {String} - Template for this email
232
- // transport {Number} - Last used transport
233
- // concatSubject {String|Boolean} - Email concatenation subject
234
- // ---
235
- // mailOptions {[Object]} - Array of nodeMailer's `mailOptions`
236
- // mailOptions.to {String|Array} - [REQUIRED]
237
- // mailOptions.from {String}
238
- // mailOptions.text {String|Boolean}
239
- // mailOptions.html (String)
240
- // mailOptions.subject {String}
241
- // mailOptions.Other nodeMailer `sendMail` options...
242
-
243
- if (this.type === 'server') {
244
- const scheduler = new JoSk({
245
- db: opts.db,
246
- debug: this.debug,
247
- prefix: `mailTimeQueue${this.prefix}`,
248
- resetOnInit: false,
249
- zombieTime: this.zombieTime,
250
- minRevolvingDelay: this.minRevolvingDelay,
251
- maxRevolvingDelay: this.maxRevolvingDelay
252
- });
253
189
 
254
- scheduler.setInterval(this.___send.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
190
+ if (typeof opts.concatDelay === 'number') {
191
+ this.concatDelay = opts.concatDelay;
192
+ } else if (typeof opts.concatThrottling === 'number') {
193
+ this.concatDelay = opts.concatThrottling * 1000;
194
+ } else {
195
+ this.concatDelay = 60000;
255
196
  }
256
- }
257
-
258
- static get Template() {
259
- return DEFAULT_TEMPLATE;
260
- }
261
-
262
- static set Template(newVal) {
263
- DEFAULT_TEMPLATE = newVal;
264
- }
265
197
 
266
- ___compileMailOpts(transport, task) {
267
- let _mailOpts = {};
198
+ this._debug('DEBUG ON {debug: true}');
199
+ this._debug(`INITIALIZING [type: ${this.type}]`);
200
+ this._debug(`INITIALIZING [strategy: ${this.strategy}]`);
201
+ this._debug(`INITIALIZING [josk.adapter: ${opts?.josk?.adapter}]`);
202
+ this._debug(`INITIALIZING [prefix: ${this.prefix}]`);
203
+ this._debug(`INITIALIZING [retries: ${this.retries}]`);
204
+ this._debug(`INITIALIZING [failsToNext: ${this.failsToNext}]`);
205
+ this._debug(`INITIALIZING [onError: ${this.onError}]`);
206
+ this._debug(`INITIALIZING [onSent: ${this.onSent}]`);
268
207
 
269
- if (!transport) {
270
- throw new Error('[mail-time] [sendMail] [___compileMailOpts] transport not available or misconfiguration is in place!');
271
- }
208
+ /** SERVER-SPECIFIC CHECKS AND CONFIG */
209
+ if (this.type === 'server') {
210
+ if (this.transports.constructor !== Array || !this.transports.length) {
211
+ throw new Error('[mail-time] {transports} is required for {type: "server"} and must be an Array, like returned from `nodemailer.createTransport`');
212
+ }
272
213
 
273
- if (transport._options && typeof transport._options === 'object' && transport._options !== null && transport._options.mailOptions) {
274
- _mailOpts = merge(_mailOpts, transport._options.mailOptions);
275
- }
214
+ if (typeof opts.josk !== 'object') {
215
+ throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}');
216
+ }
276
217
 
277
- if (transport.options && typeof transport.options === 'object' && transport.options !== null && transport.options.mailOptions) {
278
- _mailOpts = merge(_mailOpts, transport.options.mailOptions);
279
- }
218
+ if (typeof opts.josk.adapter !== 'object') {
219
+ throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class');
220
+ }
280
221
 
281
- _mailOpts = merge(_mailOpts, {
282
- html: '',
283
- text: '',
284
- subject: ''
285
- });
222
+ this.josk = { ...opts.josk };
286
223
 
287
- for (let i = 0; i < task.mailOptions.length; i++) {
288
- if (task.mailOptions[i].html) {
289
- if (task.mailOptions.length > 1) {
290
- _mailOpts.html += this.___render(this.concatDelimiter, task.mailOptions[i]) + this.___render(task.mailOptions[i].html, task.mailOptions[i]);
291
- } else {
292
- _mailOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]);
224
+ if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'mongo') {
225
+ if (!opts.josk.adapter.db) {
226
+ throw new Error('[mail-time] {josk.adapter.db} option required for {josk.adapter.type: "mongo"}');
293
227
  }
294
- delete task.mailOptions[i].html;
228
+ this.josk.adapter = new MongoAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
295
229
  }
296
230
 
297
- if (task.mailOptions[i].text) {
298
- if (task.mailOptions.length > 1) {
299
- _mailOpts.text += '\r\n' + this.___render(task.mailOptions[i].text, task.mailOptions[i]);
300
- } else {
301
- _mailOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]);
231
+ if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'redis') {
232
+ if (!opts.josk.adapter.client) {
233
+ throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "redis"}');
302
234
  }
303
- delete task.mailOptions[i].text;
235
+ this.josk.adapter = new RedisAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
304
236
  }
305
237
 
306
- _mailOpts = merge(_mailOpts, task.mailOptions[i]);
307
- }
238
+ this.josk.minRevolvingDelay = (typeof opts.josk.minRevolvingDelay === 'number') ? opts.josk.minRevolvingDelay : 512;
239
+ this.josk.maxRevolvingDelay = (typeof opts.josk.maxRevolvingDelay === 'number') ? opts.josk.maxRevolvingDelay : 2048;
240
+ this.josk.zombieTime = (typeof opts.josk.zombieTime === 'number') ? opts.josk.zombieTime : 32786;
308
241
 
309
- if (_mailOpts.html && (task.template || this.template)) {
310
- _mailOpts.html = this.___render((task.template || this.template), _mailOpts);
311
- }
242
+ this.scheduler = new JoSk({
243
+ debug: this.debug,
244
+ ...this.josk,
245
+ });
312
246
 
313
- if (task.mailOptions.length > 1) {
314
- _mailOpts.subject = task.concatSubject || this.concatSubject || _mailOpts.subject;
315
- }
247
+ process.nextTick(async () => {
248
+ if ((await this.scheduler.ping()).status !== 'OK') {
249
+ throw new Error('[mail-time] [MailTime#ping] can not connect to storage, make sure it is available and properly configured');
250
+ }
251
+ });
316
252
 
317
- if (!_mailOpts.from && this.from) {
318
- _mailOpts.from = this.from(transport);
253
+ this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
319
254
  }
255
+ }
320
256
 
321
- return _mailOpts;
257
+ static get Template() {
258
+ return DEFAULT_TEMPLATE;
259
+ }
260
+
261
+ static set Template(newVal) {
262
+ DEFAULT_TEMPLATE = newVal;
322
263
  }
323
264
 
324
265
  /**
266
+ * @async
325
267
  * @memberOf MailTime
326
- * @name ___send
327
- * @param ready {Function} - See JoSk NPM package
328
- * @returns {void}
268
+ * @name ping
269
+ * @description Check package readiness and connection to Storage
270
+ * @returns {Promise<object>}
271
+ * @throws {mix}
329
272
  */
330
- ___send(ready) {
331
- const cursor = this.collection.find({
332
- isSent: false,
333
- sendAt: {
334
- $lte: new Date()
335
- },
336
- tries: {
337
- $lt: this.maxTries
338
- }
339
- }, {
340
- projection: {
341
- _id: 1,
342
- tries: 1,
343
- template: 1,
344
- transport: 1,
345
- mailOptions: 1,
346
- concatSubject: 1
347
- }
348
- });
349
-
350
- cursor.forEach(async (task) => {
351
- try {
352
- await this.collection.updateOne({
353
- _id: task._id
354
- }, {
355
- $set: {
356
- isSent: true
357
- },
358
- $inc: {
359
- tries: 1
360
- }
361
- });
362
-
363
- let transport;
364
- let transportIndex;
365
- if (this.strategy === 'balancer') {
366
- this.transport = this.transport + 1;
367
- if (this.transport >= this.transports.length) {
368
- this.transport = 0;
369
- }
370
- transportIndex = this.transport;
371
- transport = this.transports[transportIndex];
372
- } else {
373
- transportIndex = task.transport;
374
- transport = this.transports[transportIndex];
375
- }
376
-
377
- const _mailOpts = this.___compileMailOpts(transport, task);
378
-
379
- _debug(this.debug, '[sendMail] [sending] To:', _mailOpts.to);
380
- transport.sendMail(_mailOpts, (error, info) => {
381
- if (error) {
382
- this.___handleError(task, error, info);
383
- return;
384
- }
385
-
386
- if (info.accepted && !info.accepted.length) {
387
- this.___handleError(task, 'Message not accepted or Greeting never received', info);
388
- return;
389
- }
390
-
391
- _debug(this.debug, `email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, _mailOpts.to);
392
-
393
- if (!this.keepHistory) {
394
- this.collection.deleteOne({
395
- _id: task._id
396
- }).catch(mongoErrorHandler);
397
- }
398
-
399
- this.___triggerCallbacks(void 0, task, info);
400
- return;
401
- });
402
- } catch (e) {
403
- _logError('Exception during runtime:', e);
404
- this.___handleError(task, e, {});
405
- }
406
- }).catch((forEachError) => {
407
- _logError('[___send] [forEach] [forEachError]', forEachError);
408
- }).finally(() => {
409
- ready();
410
- cursor.close();
411
- });
273
+ async ping() {
274
+ this._debug('[ping]');
275
+ const schedulerPing = await this.scheduler.ping();
276
+ if (schedulerPing.status !== 'OK') {
277
+ return schedulerPing;
278
+ }
279
+ return await this.queue.ping();
412
280
  }
413
281
 
414
282
  /**
415
283
  * @memberOf MailTime
416
284
  * @name send
417
285
  * @description alias of `sendMail`
418
- * @returns {void}
286
+ * @returns {Promise<string>} uuid of the email
419
287
  */
420
- send(opts, callback) {
421
- this.sendMail(opts, callback);
288
+ async send(opts) {
289
+ this._debug('[send]', opts);
290
+ return await this.sendMail(opts);
422
291
  }
423
292
 
424
293
  /**
294
+ * @async
425
295
  * @memberOf MailTime
426
296
  * @name sendMail
427
- * @param opts {Object} - Letter options with next properties:
428
- * @param opts.sendAt {Date} - When email should be sent
429
- * @param opts.template {String} - Template string
430
- * @param opts[key] {mix} - Other MailOptions according to NodeMailer lib documentation
431
- * @param callback {Function} - [OPTIONAL] Callback function
432
- * @returns {void}
297
+ * @description add email to the queue or append to existing letter if {concatEmails: true}
298
+ * @param opts {object} - email options
299
+ * @param opts.sendAt {Date|number} - When email should be sent
300
+ * @param opts.template {string} - Email-specific template
301
+ * @param opts[key] {mix} - Other NodeMailer's options
302
+ * @returns {Promise<string>} uuid of the email
303
+ * @throws {Error}
433
304
  */
434
- sendMail(opts = {}, callback = noop) {
435
- _debug(this.debug, '[sendMail] [attempt] To:', opts.to);
305
+ async sendMail(opts = {}) {
306
+ this._debug('[sendMail]', opts);
436
307
  if (!opts.html && !opts.text) {
437
308
  throw new Error('`html` nor `text` field is presented, at least one of those fields is required');
438
309
  }
439
310
 
440
- if (!opts.sendAt || Object.prototype.toString.call(opts.sendAt) !== '[object Date]') {
441
- opts.sendAt = new Date();
311
+ let sendAt = opts.sendAt;
312
+ if (!sendAt) {
313
+ sendAt = Date.now();
442
314
  }
443
315
 
444
- if (typeof opts.template !== 'string') {
445
- opts.template = false;
316
+ if (sendAt instanceof Date) {
317
+ sendAt = +sendAt;
446
318
  }
447
319
 
448
- if (typeof opts.concatSubject !== 'string') {
449
- opts.concatSubject = false;
320
+ if (typeof sendAt !== 'number') {
321
+ sendAt = Date.now();
450
322
  }
451
323
 
452
- let _sendAt = opts.sendAt;
453
- const _template = opts.template;
454
- const _concatSubject = opts.concatSubject;
455
- delete opts.sendAt;
456
- delete opts.template;
457
- delete opts.concatSubject;
324
+ let template = opts.template;
325
+ if (typeof template !== 'string') {
326
+ template = false;
327
+ }
328
+
329
+ let concatSubject = opts.concatSubject;
330
+ if (typeof concatSubject !== 'string') {
331
+ concatSubject = false;
332
+ }
333
+
334
+ const mailOptions = { ...opts };
335
+ delete mailOptions.sendAt;
336
+ delete mailOptions.template;
337
+ delete mailOptions.concatSubject;
458
338
 
459
- if (typeof opts.to !== 'string' && (!(opts.to instanceof Array) || !opts.to.length)) {
339
+ if (typeof mailOptions.to !== 'string' && (!(mailOptions.to instanceof Array) || !mailOptions.to.length)) {
460
340
  throw new Error('[mail-time] `mailOptions.to` is required and must be a string or non-empty Array');
461
341
  }
462
342
 
463
343
  if (this.concatEmails) {
464
- _sendAt = new Date(+_sendAt + this.concatThrottling);
465
- this.collection.findOne({
466
- to: opts.to,
467
- isSent: false,
468
- sendAt: {
469
- $lte: _sendAt
470
- }
471
- }, {
472
- projection: {
473
- _id: 1,
474
- mailOptions: 1
475
- }
476
- }).then((task) => {
477
- if (task) {
478
- const queue = task.mailOptions || [];
479
-
480
- for (let i = 0; i < queue.length; i++) {
481
- if (equals(queue[i], opts)) {
482
- return;
483
- }
484
- }
344
+ sendAt = sendAt + this.concatDelay;
345
+ const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
485
346
 
486
- queue.push(opts);
487
- this.collection.updateOne({
488
- _id: task._id
489
- }, {
490
- $set: {
491
- mailOptions: queue
492
- }
493
- }).then(() => {
494
- const _id = task._id.toHexString();
495
- if (!this.callbacks[_id]) {
496
- this.callbacks[_id] = [];
497
- }
498
- this.callbacks[_id].push(callback);
499
- }).catch(mongoErrorHandler);
500
- return;
501
- }
347
+ if (task) {
348
+ const pendingMailOptions = task.mailOptions || [];
502
349
 
503
- this.___addToQueue({
504
- sendAt: _sendAt,
505
- template: _template,
506
- mailOptions: opts,
507
- concatSubject: _concatSubject
508
- }, callback);
509
- }).catch(mongoErrorHandler);
350
+ for (let i = 0; i < pendingMailOptions.length; i++) {
351
+ if (equals(pendingMailOptions[i], mailOptions)) {
352
+ return task.uuid;
353
+ }
354
+ }
510
355
 
511
- return;
356
+ pendingMailOptions.push(opts);
357
+ await this.queue.update(task, {
358
+ mailOptions: pendingMailOptions
359
+ });
360
+ return task.uuid;
361
+ }
512
362
  }
513
363
 
514
- this.___addToQueue({
515
- sendAt: _sendAt,
516
- template: _template,
517
- mailOptions: opts,
518
- concatSubject: _concatSubject
519
- }, callback);
364
+ return await this.___addToQueue({
365
+ sendAt,
366
+ template,
367
+ concatSubject,
368
+ mailOptions: mailOptions,
369
+ });
370
+ }
520
371
 
521
- return;
372
+ /**
373
+ * @memberOf MailTime
374
+ * @name cancel
375
+ * @description alias of `cancelMail`
376
+ * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
377
+ */
378
+ async cancel(uuid) {
379
+ this._debug('[cancel]', uuid);
380
+ return await this.cancelMail(uuid);
522
381
  }
523
382
 
524
383
  /**
384
+ * @async
385
+ * @memberOf MailTime
386
+ * @name cancelMail
387
+ * @description remove email from the queue or mark as `isCancelled`
388
+ * @param uuid {string|Promise<string>} - uuid returned from `send` or `sendMail`
389
+ * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
390
+ */
391
+ async cancelMail(uuid) {
392
+ this._debug('[cancelMail]', uuid);
393
+ if (typeof uuid === 'object' && uuid instanceof Promise) {
394
+ return await this.queue.cancel(await uuid);
395
+ }
396
+ return await this.queue.cancel(uuid);
397
+ }
398
+
399
+ /**
400
+ * @async
525
401
  * @memberOf MailTime
526
402
  * @name ___handleError
527
- * @param task {Object} - Email task record form Mongo
528
- * @param error {mix} - Error String/Object/Error
529
- * @param info {Object} - Info object returned from nodemailer
530
- * @returns {void}
403
+ * @description Handle runtime errors and pass to `onError` callback
404
+ * @param task {object} - Email task record form Storage
405
+ * @param error {mix} - Error String/Object/Error
406
+ * @param info {object} - Info object returned from nodemailer
407
+ * @returns {Promise<void 0>}
531
408
  */
532
- ___handleError(task, error, info) {
409
+ async ___handleError(task, error, info) {
410
+ this._debug('[private handleError]', { task, error, info });
533
411
  if (!task) {
534
412
  return;
535
413
  }
536
414
 
537
415
  if (task.tries >= this.maxTries) {
416
+ task.isSent = false;
417
+ task.isFailed = true;
418
+
538
419
  if (!this.keepHistory) {
539
- this.collection.deleteOne({
540
- _id: task._id
541
- }).catch(mongoErrorHandler);
420
+ await this.queue.remove(task);
421
+ } else {
422
+ await this.queue.update(task, {
423
+ isSent: task.isSent,
424
+ isFailed: task.isFailed,
425
+ });
542
426
  }
543
427
 
544
- _debug(this.debug, `Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
545
- this.___triggerCallbacks(error, task, info);
428
+ this._debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
429
+ this.onError(error, task, info);
546
430
  return;
547
431
  }
548
432
 
@@ -555,61 +439,57 @@ export default class MailTime {
555
439
  }
556
440
  }
557
441
 
558
- this.collection.updateOne({
559
- _id: task._id
560
- }, {
561
- $set: {
562
- isSent: false,
563
- sendAt: new Date(Date.now() + this.interval),
564
- transport: transportIndex
565
- }
566
- }).catch(mongoErrorHandler);
442
+ await this.queue.update(task, {
443
+ isSent: false,
444
+ sendAt: Date.now() + this.retryDelay,
445
+ transport: transportIndex,
446
+ });
567
447
 
568
- _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);
448
+ 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);
569
449
  }
570
450
 
571
451
  /**
452
+ * @async
572
453
  * @memberOf MailTime
573
454
  * @name ___addToQueue
574
- * @param opts {Object} - Letter options with next properties:
575
- * @param opts.sendAt {Date} - When email should be sent
576
- * @param opts.template {String} - Email template
577
- * @param opts.mailOptions {Object} - MailOptions according to NodeMailer lib
578
- * @param opts.concatSubject {String} - Email subject used when sending multiple concatenated emails
579
- * @param callback {Function} - [OPTIONAL] Callback function
580
- * @returns {void}
455
+ * @description Prepare task's object and push to the queue
456
+ * @param opts {object} - Email options with next properties:
457
+ * @param opts.sendAt {number} - When email should be sent
458
+ * @param opts.template {string} - Email-specific template
459
+ * @param opts.mailOptions {object} - MailOptions according to NodeMailer lib
460
+ * @param opts.concatSubject {string} - Email subject used when sending concatenated email
461
+ * @returns {Promise<string>} message uuid
581
462
  */
582
- ___addToQueue(opts, callback) {
583
- _debug(this.debug, '[sendMail] [adding to queue] To:', opts.mailOptions.to);
463
+ async ___addToQueue(opts) {
464
+ this._debug('[private addToQueue]', opts);
584
465
  const task = {
466
+ uuid: crypto.randomUUID(),
585
467
  tries: 0,
586
468
  isSent: false,
587
469
  sendAt: opts.sendAt,
470
+ isFailed: false,
588
471
  template: opts.template,
589
472
  transport: this.transport,
473
+ isCancelled: false,
590
474
  mailOptions: [opts.mailOptions],
591
- concatSubject: opts.concatSubject
475
+ concatSubject: opts.concatSubject,
592
476
  };
593
477
 
594
478
  if (this.concatEmails) {
595
479
  task.to = opts.mailOptions.to;
596
480
  }
597
481
 
598
- this.collection.insertOne(task).then((r) => {
599
- const _id = r.insertedId.toHexString();
600
- if (!this.callbacks[_id]) {
601
- this.callbacks[_id] = [];
602
- }
603
- this.callbacks[_id].push(callback);
604
- }).catch(mongoErrorHandler);
482
+ await this.queue.push(task);
483
+ return task.uuid;
605
484
  }
606
485
 
607
486
  /**
608
487
  * @memberOf MailTime
609
488
  * @name ___render
610
- * @param string {String} - Template with Spacebars/Blaze/Mustache-like placeholders
611
- * @param replacements {Object} - Blaze/Mustache-like helpers Object
612
- * @returns {String}
489
+ * @description Render templates
490
+ * @param string {string} - Template with Mustache-like placeholders
491
+ * @param replacements {object} - Blaze/Mustache-like helpers Object
492
+ * @returns {string}
613
493
  */
614
494
  ___render(_string, replacements) {
615
495
  let i;
@@ -636,19 +516,154 @@ export default class MailTime {
636
516
 
637
517
  /**
638
518
  * @memberOf MailTime
639
- * @name ___triggerCallbacks
640
- * @param error {mix} - Error thrown during sending email
641
- * @param task {Object} - Task record from mongodb
642
- * @param info {Object} - Info object returned from NodeMailer's `sendMail` method
519
+ * @name ___compileMailOpts
520
+ * @description Run various checks, compile options, and render template
521
+ * @param transport {object} - Current transport
522
+ * @param info {object} - Info object returned from NodeMailer's `sendMail` method
643
523
  * @returns {void 0}
644
524
  */
645
- ___triggerCallbacks(error, task, info) {
646
- const _id = task._id.toHexString();
647
- if (this.callbacks[_id] && this.callbacks[_id].length) {
648
- this.callbacks[_id].forEach((cb, i) => {
649
- cb(error, info, task?.mailOptions?.[i]);
525
+ ___compileMailOpts(transport, task) {
526
+ let compiledOpts = {};
527
+
528
+ if (!transport) {
529
+ throw new Error('[mail-time] [sendMail] [___compileMailOpts] {transport} is not available or misconfigured!');
530
+ }
531
+
532
+ if (transport._options && typeof transport._options === 'object' && transport._options !== null && transport._options.mailOptions) {
533
+ compiledOpts = merge(compiledOpts, transport._options.mailOptions);
534
+ }
535
+
536
+ if (transport.options && typeof transport.options === 'object' && transport.options !== null && transport.options.mailOptions) {
537
+ compiledOpts = merge(compiledOpts, transport.options.mailOptions);
538
+ }
539
+
540
+ compiledOpts = merge(compiledOpts, {
541
+ html: '',
542
+ text: '',
543
+ subject: ''
544
+ });
545
+
546
+ for (let i = 0; i < task.mailOptions.length; i++) {
547
+ if (task.mailOptions[i].html) {
548
+ if (task.mailOptions.length > 1) {
549
+ compiledOpts.html += this.___render(this.concatDelimiter, task.mailOptions[i]) + this.___render(task.mailOptions[i].html, task.mailOptions[i]);
550
+ } else {
551
+ compiledOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]);
552
+ }
553
+ delete task.mailOptions[i].html;
554
+ }
555
+
556
+ if (task.mailOptions[i].text) {
557
+ if (task.mailOptions.length > 1) {
558
+ compiledOpts.text += '\r\n' + this.___render(task.mailOptions[i].text, task.mailOptions[i]);
559
+ } else {
560
+ compiledOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]);
561
+ }
562
+ delete task.mailOptions[i].text;
563
+ }
564
+
565
+ compiledOpts = merge(compiledOpts, task.mailOptions[i]);
566
+ }
567
+
568
+ if (compiledOpts.html && (task.template || this.template)) {
569
+ compiledOpts.html = this.___render((task.template || this.template), compiledOpts);
570
+ }
571
+
572
+ if (task.mailOptions.length > 1) {
573
+ compiledOpts.subject = task.concatSubject || this.concatSubject || compiledOpts.subject;
574
+ }
575
+
576
+ if (!compiledOpts.from && this.from) {
577
+ compiledOpts.from = this.from(transport);
578
+ }
579
+
580
+ return compiledOpts;
581
+ }
582
+
583
+ /**
584
+ * @async
585
+ * @memberOf MailTime
586
+ * @name ___send
587
+ * @description send email using nodemailer's transport
588
+ * @param task {object} - email's task object from Storage
589
+ * @returns {Promise<void 0>}
590
+ */
591
+ async ___send(task) {
592
+ this._debug('[private send]', task);
593
+ try {
594
+ if (task.isSent === true || task.isFailed === true || task.isCancelled === true) {
595
+ return;
596
+ }
597
+
598
+ task.tries++;
599
+ const isUpdated = await this.queue.update(task, {
600
+ isSent: true,
601
+ tries: task.tries
602
+ });
603
+
604
+ if (!isUpdated) {
605
+ logError('[private send] [queue.update] Not updated!');
606
+ return;
607
+ }
608
+
609
+ let transport;
610
+ let transportIndex;
611
+ if (this.strategy === 'balancer') {
612
+ this.transport = this.transport + 1;
613
+ if (this.transport >= this.transports.length) {
614
+ this.transport = 0;
615
+ }
616
+ transportIndex = this.transport;
617
+ transport = this.transports[transportIndex];
618
+ } else {
619
+ transportIndex = task.transport;
620
+ transport = this.transports[transportIndex];
621
+ }
622
+
623
+ const compiledOpts = this.___compileMailOpts(transport, task);
624
+
625
+ await new Promise((resolve) => {
626
+ transport.sendMail(compiledOpts, async (error, info) => {
627
+ this._debug('[private send] [sending]', { error, info });
628
+ if (error) {
629
+ await this.___handleError(task, error, info);
630
+ resolve();
631
+ return;
632
+ }
633
+
634
+ if (info.accepted && !info.accepted.length) {
635
+ await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
636
+ resolve();
637
+ return;
638
+ }
639
+
640
+ this._debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
641
+
642
+ if (!this.keepHistory) {
643
+ await this.queue.remove(task);
644
+ }
645
+
646
+ task.isSent = true;
647
+ this.onSent(task, info);
648
+ resolve();
649
+ });
650
650
  });
651
+ } catch (e) {
652
+ logError('Exception during runtime:', e);
653
+ this.___handleError(task, e, {});
651
654
  }
652
- delete this.callbacks[_id];
655
+ }
656
+
657
+ /**
658
+ * @memberOf MailTime
659
+ * @name ___iterate
660
+ * @description Iterate over queued tasks
661
+ * @returns {Promise}
662
+ */
663
+ async ___iterate() {
664
+ this._debug('[private iterate]');
665
+ return await this.queue.iterate();
653
666
  }
654
667
  }
668
+
669
+ export { MailTime, MongoQueue, RedisQueue };