mail-time 1.3.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,24 +1,22 @@
1
- const JoSk = require('josk');
2
- const noop = () => {};
3
- const merge = require('deepmerge');
4
- const _debug = (isDebug, ...args) => {
5
- if (isDebug) {
6
- console.info.call(console, '[DEBUG] [mail-time]', `${new Date}`, ...args);
7
- }
8
- };
9
- const _logError = (...args) => {
10
- console.error.call(console, '[ERROR] [mail-time]', `${new Date}`, ...args);
11
- };
12
-
13
- const mongoErrorHandler = (error) => {
14
- if (error) {
15
- _logError('[mongoErrorHandler]:', error);
16
- console.trace();
17
- }
18
- };
19
-
20
- let equals;
21
- equals = (a, b) => {
1
+ import { JoSk, RedisAdapter, MongoAdapter } from 'josk';
2
+ import merge from 'deepmerge';
3
+
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';
8
+
9
+ const noop = () => {};
10
+
11
+ /**
12
+ * Check if entities of various types are equal
13
+ * including edge cases like unordered Objects and Array
14
+ * @function equals
15
+ * @param {mix} a
16
+ * @param {mix} b
17
+ * @returns {boolean}
18
+ */
19
+ const equals = (a, b) => {
22
20
  let i;
23
21
  if (a === b) {
24
22
  return true;
@@ -92,39 +90,82 @@ equals = (a, b) => {
92
90
 
93
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>';
94
92
 
95
- module.exports = class MailTime {
93
+ /** Class of 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
+ */
96
116
  constructor (opts) {
97
117
  if (!opts || typeof opts !== 'object' || opts === null) {
98
118
  throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor');
99
119
  }
100
120
 
101
- if (!opts.db) {
102
- 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
+ }
103
135
  }
104
136
 
105
- this.callbacks = {};
106
- this.type = (!opts.type || (opts.type !== 'client' && opts.type !== 'server')) ? 'server' : opts.type;
107
137
  this.debug = (opts.debug !== true) ? false : true;
108
- this.prefix = opts.prefix || '';
109
- this.maxTries = ((opts.maxTries && !isNaN(opts.maxTries)) ? parseInt(opts.maxTries) : 59) + 1;
110
- this.interval = ((opts.interval && !isNaN(opts.interval)) ? parseInt(opts.interval) : 60) * 1000;
111
- this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
112
- this.zombieTime = opts.zombieTime || 32786;
113
- this.keepHistory = opts.keepHistory || false;
138
+ this._debug = (...args) => {
139
+ debug(this.debug, ...args);
140
+ };
114
141
 
115
- this.revolvingInterval = opts.revolvingInterval || 1536;
116
- this.minRevolvingDelay = opts.minRevolvingDelay || 512;
117
- 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 : '';
118
144
 
119
- if (this.interval < 2048 || isNaN(this.interval)) {
120
- 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;
121
151
  }
122
152
 
123
- if (this.zombieTime < 8192 || isNaN(this.zombieTime)) {
124
- 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;
125
159
  }
126
160
 
127
- 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;
128
169
  this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup';
129
170
  this.transports = opts.transports || [];
130
171
  this.transport = 0;
@@ -145,64 +186,71 @@ module.exports = class MailTime {
145
186
  this.concatEmails = (opts.concatEmails !== true) ? false : true;
146
187
  this.concatSubject = (opts.concatSubject && typeof opts.concatSubject === 'string') ? opts.concatSubject : 'Multiple notifications';
147
188
  this.concatDelimiter = (opts.concatDelimiter && typeof opts.concatDelimiter === 'string') ? opts.concatDelimiter : '<hr>';
148
- this.concatThrottling = ((opts.concatThrottling && !isNaN(opts.concatThrottling)) ? parseInt(opts.concatThrottling) : 60) * 1000;
149
189
 
150
- if (this.concatThrottling < 2048) {
151
- this.concatThrottling = 3072;
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;
152
196
  }
153
197
 
154
- _debug(this.debug, 'DEBUG ON {debug: true}');
155
- _debug(this.debug, `INITIALIZING [type: ${this.type}]`);
156
- _debug(this.debug, `INITIALIZING [strategy: ${this.strategy}]`);
157
- _debug(this.debug, `INITIALIZING [prefix: ${this.prefix}]`);
158
- _debug(this.debug, `INITIALIZING [maxTries: ${this.maxTries}]`);
159
- _debug(this.debug, `INITIALIZING [failsToNext: ${this.failsToNext}]`);
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}]`);
207
+
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
+ }
160
213
 
161
- if (opts.type === 'server' && (this.transports.constructor !== Array || !this.transports.length)) {
162
- throw new Error('[mail-time] transports is required and must be an Array, like returned from `nodemailer.createTransport`');
163
- }
214
+ if (typeof opts.josk !== 'object') {
215
+ throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}');
216
+ }
164
217
 
165
- this.collection = opts.db.collection(`__mailTimeQueue__${this.prefix}`);
166
- this.collection.createIndex({ isSent: 1, to: 1, sendAt: 1 }, (indexError) => {
167
- if (indexError) {
168
- _logError('[createIndex]', indexError);
218
+ if (typeof opts.josk.adapter !== 'object') {
219
+ throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class');
169
220
  }
170
- });
171
- this.collection.createIndex({ isSent: 1, sendAt: 1, tries: 1 }, { background: true }, (indexError) => {
172
- if (indexError) {
173
- _logError('[createIndex]', indexError);
221
+
222
+ this.josk = { ...opts.josk };
223
+
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"}');
227
+ }
228
+ this.josk.adapter = new MongoAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
174
229
  }
175
- });
176
- // Schema:
177
- // _id
178
- // to {String|[String]}
179
- // tries {Number} - qty of send attempts
180
- // sendAt {Date} - When letter should be sent
181
- // isSent {Boolean} - Email status
182
- // template {String} - Template for this email
183
- // transport {Number} - Last used transport
184
- // concatSubject {String|Boolean} - Email concatenation subject
185
- // ---
186
- // mailOptions {[Object]} - Array of nodeMailer's `mailOptions`
187
- // mailOptions.to {String|Array} - [REQUIRED]
188
- // mailOptions.from {String}
189
- // mailOptions.text {String|Boolean}
190
- // mailOptions.html (String)
191
- // mailOptions.subject {String}
192
- // mailOptions.Other nodeMailer `sendMail` options...
193
230
 
194
- if (this.type === 'server') {
195
- const scheduler = new JoSk({
196
- db: opts.db,
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"}');
234
+ }
235
+ this.josk.adapter = new RedisAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
236
+ }
237
+
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;
241
+
242
+ this.scheduler = new JoSk({
197
243
  debug: this.debug,
198
- prefix: `mailTimeQueue${this.prefix}`,
199
- resetOnInit: false,
200
- zombieTime: this.zombieTime,
201
- minRevolvingDelay: this.minRevolvingDelay,
202
- maxRevolvingDelay: this.maxRevolvingDelay
244
+ ...this.josk,
245
+ });
246
+
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
+ }
203
251
  });
204
252
 
205
- scheduler.setInterval(this.___send.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
253
+ this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
206
254
  }
207
255
  }
208
256
 
@@ -214,372 +262,234 @@ module.exports = class MailTime {
214
262
  DEFAULT_TEMPLATE = newVal;
215
263
  }
216
264
 
217
- ___compileMailOpts(transport, task) {
218
- let _mailOpts = {};
219
-
220
- if (!transport) {
221
- throw new Error('[mail-time] [sendMail] [___compileMailOpts] transport not available or misconfiguration is in place!');
222
- }
223
-
224
- if (transport._options && typeof transport._options === 'object' && transport._options !== null && transport._options.mailOptions) {
225
- _mailOpts = merge(_mailOpts, transport._options.mailOptions);
226
- }
227
-
228
- if (transport.options && typeof transport.options === 'object' && transport.options !== null && transport.options.mailOptions) {
229
- _mailOpts = merge(_mailOpts, transport.options.mailOptions);
230
- }
231
-
232
- _mailOpts = merge(_mailOpts, {
233
- html: '',
234
- text: '',
235
- subject: ''
236
- });
237
-
238
- for (let i = 0; i < task.mailOptions.length; i++) {
239
- if (task.mailOptions[i].html) {
240
- if (task.mailOptions.length > 1) {
241
- _mailOpts.html += this.___render(this.concatDelimiter, task.mailOptions[i]) + this.___render(task.mailOptions[i].html, task.mailOptions[i]);
242
- } else {
243
- _mailOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]);
244
- }
245
- delete task.mailOptions[i].html;
246
- }
247
-
248
- if (task.mailOptions[i].text) {
249
- if (task.mailOptions.length > 1) {
250
- _mailOpts.text += '\r\n' + this.___render(task.mailOptions[i].text, task.mailOptions[i]);
251
- } else {
252
- _mailOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]);
253
- }
254
- delete task.mailOptions[i].text;
255
- }
256
-
257
- _mailOpts = merge(_mailOpts, task.mailOptions[i]);
258
- }
259
-
260
- if (_mailOpts.html && (task.template || this.template)) {
261
- _mailOpts.html = this.___render((task.template || this.template), _mailOpts);
262
- }
263
-
264
- if (task.mailOptions.length > 1) {
265
- _mailOpts.subject = task.concatSubject || this.concatSubject || _mailOpts.subject;
266
- }
267
-
268
- if (!_mailOpts.from && this.from) {
269
- _mailOpts.from = this.from(transport);
270
- }
271
-
272
- return _mailOpts;
273
- }
274
-
275
265
  /**
266
+ * @async
276
267
  * @memberOf MailTime
277
- * @name ___send
278
- * @param ready {Function} - See JoSk NPM package
279
- * @returns {void}
268
+ * @name ping
269
+ * @description Check package readiness and connection to Storage
270
+ * @returns {Promise<object>}
271
+ * @throws {mix}
280
272
  */
281
- ___send(ready) {
282
- const cursor = this.collection.find({
283
- isSent: false,
284
- sendAt: {
285
- $lte: new Date()
286
- },
287
- tries: {
288
- $lt: this.maxTries
289
- }
290
- }, {
291
- projection: {
292
- _id: 1,
293
- tries: 1,
294
- template: 1,
295
- transport: 1,
296
- mailOptions: 1,
297
- concatSubject: 1
298
- }
299
- });
300
-
301
- cursor.forEach((task) => {
302
- this.collection.updateOne({
303
- _id: task._id
304
- }, {
305
- $set: {
306
- isSent: true
307
- },
308
- $inc: {
309
- tries: 1
310
- }
311
- }, (updateError) => {
312
- if (updateError) {
313
- this.___handleError(task, updateError, {});
314
- } else {
315
- let transport;
316
- let transportIndex;
317
- if (this.strategy === 'balancer') {
318
- this.transport = this.transport + 1;
319
- if (this.transport >= this.transports.length) {
320
- this.transport = 0;
321
- }
322
- transportIndex = this.transport;
323
- transport = this.transports[transportIndex];
324
- } else {
325
- transportIndex = task.transport;
326
- transport = this.transports[transportIndex];
327
- }
328
-
329
- try {
330
- const _mailOpts = this.___compileMailOpts(transport, task);
331
-
332
- _debug(this.debug, '[sendMail] [sending] To:', _mailOpts.to);
333
- transport.sendMail(_mailOpts, (error, info) => {
334
- if (error) {
335
- this.___handleError(task, error, info);
336
- return;
337
- }
338
-
339
- if (info.accepted && !info.accepted.length) {
340
- this.___handleError(task, 'Message not accepted or Greeting never received', info);
341
- return;
342
- }
343
-
344
- _debug(this.debug, `email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, _mailOpts.to);
345
- if (this.keepHistory) {
346
- this.___triggerCallbacks(void 0, task, info);
347
- } else {
348
- this.collection.deleteOne({ _id: task._id }, () => {
349
- this.___triggerCallbacks(void 0, task, info);
350
- });
351
- }
352
-
353
- return;
354
- });
355
- } catch (e) {
356
- _logError('Exception during runtime:', e);
357
- this.___handleError(task, e, {});
358
- }
359
- }
360
- });
361
- }, (forEachError) => {
362
- ready();
363
- cursor.close();
364
- if (forEachError) {
365
- _logError('[___send] [forEach] [forEachError]', forEachError);
366
- }
367
- });
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();
368
280
  }
369
281
 
370
282
  /**
371
283
  * @memberOf MailTime
372
284
  * @name send
373
285
  * @description alias of `sendMail`
374
- * @returns {void}
286
+ * @returns {Promise<string>} uuid of the email
375
287
  */
376
- send(opts, callback) {
377
- this.sendMail(opts, callback);
288
+ async send(opts) {
289
+ this._debug('[send]', opts);
290
+ return await this.sendMail(opts);
378
291
  }
379
292
 
380
293
  /**
294
+ * @async
381
295
  * @memberOf MailTime
382
296
  * @name sendMail
383
- * @param opts {Object} - Letter options with next properties:
384
- * @param opts.sendAt {Date} - When email should be sent
385
- * @param opts.template {String} - Template string
386
- * @param opts[key] {mix} - Other MailOptions according to NodeMailer lib documentation
387
- * @param callback {Function} - [OPTIONAL] Callback function
388
- * @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}
389
304
  */
390
- sendMail(opts = {}, callback = noop) {
391
- _debug(this.debug, '[sendMail] [attempt] To:', opts.to);
305
+ async sendMail(opts = {}) {
306
+ this._debug('[sendMail]', opts);
392
307
  if (!opts.html && !opts.text) {
393
308
  throw new Error('`html` nor `text` field is presented, at least one of those fields is required');
394
309
  }
395
310
 
396
- if (!opts.sendAt || Object.prototype.toString.call(opts.sendAt) !== '[object Date]') {
397
- opts.sendAt = new Date();
311
+ let sendAt = opts.sendAt;
312
+ if (!sendAt) {
313
+ sendAt = Date.now();
398
314
  }
399
315
 
400
- if (typeof opts.template !== 'string') {
401
- opts.template = false;
316
+ if (sendAt instanceof Date) {
317
+ sendAt = +sendAt;
402
318
  }
403
319
 
404
- if (typeof opts.concatSubject !== 'string') {
405
- opts.concatSubject = false;
320
+ if (typeof sendAt !== 'number') {
321
+ sendAt = Date.now();
406
322
  }
407
323
 
408
- let _sendAt = opts.sendAt;
409
- const _template = opts.template;
410
- const _concatSubject = opts.concatSubject;
411
- delete opts.sendAt;
412
- delete opts.template;
413
- 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
+ }
414
333
 
415
- if (typeof opts.to !== 'string' && (!(opts.to instanceof Array) || !opts.to.length)) {
334
+ const mailOptions = { ...opts };
335
+ delete mailOptions.sendAt;
336
+ delete mailOptions.template;
337
+ delete mailOptions.concatSubject;
338
+
339
+ if (typeof mailOptions.to !== 'string' && (!(mailOptions.to instanceof Array) || !mailOptions.to.length)) {
416
340
  throw new Error('[mail-time] `mailOptions.to` is required and must be a string or non-empty Array');
417
341
  }
418
342
 
419
343
  if (this.concatEmails) {
420
- this.collection.findOne({
421
- to: opts.to,
422
- isSent: false,
423
- sendAt: {
424
- $lte: new Date(+_sendAt + this.concatThrottling)
425
- }
426
- }, {
427
- projection: {
428
- _id: 1,
429
- mailOptions: 1
430
- }
431
- }, (findError, task) => {
432
- if (findError) {
433
- _debug(this.debug, 'something went wrong, can\'t send email to: ', opts.to, findError);
434
- callback(findError, void 0, task);
435
- return;
436
- }
344
+ sendAt = sendAt + this.concatDelay;
345
+ const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
437
346
 
438
- if (task) {
439
- const queue = task.mailOptions || [];
347
+ if (task) {
348
+ const pendingMailOptions = task.mailOptions || [];
440
349
 
441
- for (let i = 0; i < queue.length; i++) {
442
- if (equals(queue[i], opts)) {
443
- return;
444
- }
350
+ for (let i = 0; i < pendingMailOptions.length; i++) {
351
+ if (equals(pendingMailOptions[i], mailOptions)) {
352
+ return task.uuid;
445
353
  }
446
-
447
- queue.push(opts);
448
-
449
- this.collection.updateOne({
450
- _id: task._id
451
- }, {
452
- $set: {
453
- mailOptions: queue
454
- }
455
- }, (updateError) => {
456
- if (updateError) {
457
- _debug('something went wrong, can\'t send email to: ', task.mailOptions[0].to, updateError);
458
- callback(updateError, void 0, task);
459
- }
460
- });
461
-
462
- return;
463
354
  }
464
355
 
465
- _sendAt = new Date(+_sendAt + this.concatThrottling);
466
- this.___addToQueue({
467
- sendAt: _sendAt,
468
- template: _template,
469
- mailOptions: opts,
470
- concatSubject: _concatSubject
471
- }, callback);
472
- return;
473
- });
474
-
475
- return;
356
+ pendingMailOptions.push(opts);
357
+ await this.queue.update(task, {
358
+ mailOptions: pendingMailOptions
359
+ });
360
+ return task.uuid;
361
+ }
476
362
  }
477
363
 
478
- this.___addToQueue({
479
- sendAt: _sendAt,
480
- template: _template,
481
- mailOptions: opts,
482
- concatSubject: _concatSubject
483
- }, callback);
364
+ return await this.___addToQueue({
365
+ sendAt,
366
+ template,
367
+ concatSubject,
368
+ mailOptions: mailOptions,
369
+ });
370
+ }
484
371
 
485
- 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);
486
381
  }
487
382
 
488
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
489
401
  * @memberOf MailTime
490
402
  * @name ___handleError
491
- * @param task {Object} - Email task record form Mongo
492
- * @param error {mix} - Error String/Object/Error
493
- * @param info {Object} - Info object returned from nodemailer
494
- * @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>}
495
408
  */
496
- ___handleError(task, error, info) {
409
+ async ___handleError(task, error, info) {
410
+ this._debug('[private handleError]', { task, error, info });
497
411
  if (!task) {
498
412
  return;
499
413
  }
500
414
 
501
415
  if (task.tries >= this.maxTries) {
416
+ task.isSent = false;
417
+ task.isFailed = true;
418
+
502
419
  if (!this.keepHistory) {
503
- this.collection.deleteOne({
504
- _id: task._id
505
- }, (deleteError) => {
506
- _debug(this.debug, `Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error, deleteError);
507
- this.___triggerCallbacks(error, task, info);
420
+ await this.queue.remove(task);
421
+ } else {
422
+ await this.queue.update(task, {
423
+ isSent: task.isSent,
424
+ isFailed: task.isFailed,
508
425
  });
509
426
  }
510
- } else {
511
- let transportIndex = task.transport;
512
427
 
513
- if (this.strategy === 'backup' && (task.tries % this.failsToNext) === 0) {
514
- ++transportIndex;
515
- if (transportIndex > this.transports.length - 1) {
516
- transportIndex = 0;
517
- }
518
- }
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);
430
+ return;
431
+ }
519
432
 
520
- this.collection.updateOne({
521
- _id: task._id
522
- }, {
523
- $set: {
524
- isSent: false,
525
- sendAt: new Date(Date.now() + this.interval),
526
- transport: transportIndex
527
- }
528
- }, mongoErrorHandler);
433
+ let transportIndex = task.transport;
529
434
 
530
- _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);
435
+ if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) {
436
+ ++transportIndex;
437
+ if (transportIndex > this.transports.length - 1) {
438
+ transportIndex = 0;
439
+ }
531
440
  }
441
+
442
+ await this.queue.update(task, {
443
+ isSent: false,
444
+ sendAt: Date.now() + this.retryDelay,
445
+ transport: transportIndex,
446
+ });
447
+
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);
532
449
  }
533
450
 
534
451
  /**
452
+ * @async
535
453
  * @memberOf MailTime
536
454
  * @name ___addToQueue
537
- * @param opts {Object} - Letter options with next properties:
538
- * @param opts.sendAt {Date} - When email should be sent
539
- * @param opts.template {String} - Email template
540
- * @param opts.mailOptions {Object} - MailOptions according to NodeMailer lib
541
- * @param opts.concatSubject {String} - Email subject used when sending multiple concatenated emails
542
- * @param callback {Function} - [OPTIONAL] Callback function
543
- * @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
544
462
  */
545
- ___addToQueue(opts, callback) {
546
- _debug(this.debug, '[sendMail] [adding to queue] To:', opts.mailOptions.to);
463
+ async ___addToQueue(opts) {
464
+ this._debug('[private addToQueue]', opts);
547
465
  const task = {
466
+ uuid: crypto.randomUUID(),
548
467
  tries: 0,
549
468
  isSent: false,
550
469
  sendAt: opts.sendAt,
470
+ isFailed: false,
551
471
  template: opts.template,
552
472
  transport: this.transport,
473
+ isCancelled: false,
553
474
  mailOptions: [opts.mailOptions],
554
- concatSubject: opts.concatSubject
475
+ concatSubject: opts.concatSubject,
555
476
  };
556
477
 
557
478
  if (this.concatEmails) {
558
479
  task.to = opts.mailOptions.to;
559
480
  }
560
481
 
561
- this.collection.insertOne(task, (insertError, r) => {
562
- if (insertError) {
563
- _logError('something went wrong, can\'t send email to: ', opts.mailOptions[0].to, insertError);
564
- callback(insertError, void 0, opts);
565
- return;
566
- }
567
-
568
- const _id = r.insertedId.toHexString();
569
- if (!this.callbacks[_id]) {
570
- this.callbacks[_id] = [];
571
- }
572
- this.callbacks[_id].push(callback);
573
- return;
574
- });
482
+ await this.queue.push(task);
483
+ return task.uuid;
575
484
  }
576
485
 
577
486
  /**
578
487
  * @memberOf MailTime
579
488
  * @name ___render
580
- * @param string {String} - Template with Spacebars/Blaze/Mustache-like placeholders
581
- * @param replacements {Object} - Blaze/Mustache-like helpers Object
582
- * @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}
583
493
  */
584
494
  ___render(_string, replacements) {
585
495
  let i;
@@ -606,19 +516,154 @@ module.exports = class MailTime {
606
516
 
607
517
  /**
608
518
  * @memberOf MailTime
609
- * @name ___triggerCallbacks
610
- * @param error {mix} - Error thrown during sending email
611
- * @param task {Object} - Task record from mongodb
612
- * @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
613
523
  * @returns {void 0}
614
524
  */
615
- ___triggerCallbacks(error, task, info) {
616
- const _id = task._id.toHexString();
617
- if (this.callbacks[_id] && this.callbacks[_id].length) {
618
- this.callbacks[_id].forEach((cb, i) => {
619
- 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
+ });
620
650
  });
651
+ } catch (e) {
652
+ logError('Exception during runtime:', e);
653
+ this.___handleError(task, e, {});
621
654
  }
622
- delete this.callbacks[_id];
623
655
  }
624
- };
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();
666
+ }
667
+ }
668
+
669
+ export { MailTime, MongoQueue, RedisQueue };