diff --git a/.meteorignore b/.meteorignore index 1cc4280..52055df 100644 --- a/.meteorignore +++ b/.meteorignore @@ -5,6 +5,7 @@ .DS_Store .eslintcache .eslintrc +adapters/blank-example.js ascii cover.jpg cover.png diff --git a/.npmignore b/.npmignore index 1d45274..6b7ac8b 100644 --- a/.npmignore +++ b/.npmignore @@ -7,6 +7,7 @@ .meteorignore .npm .versions +adapters/blank-example.js ascii cover.jpg cover.png diff --git a/.versions b/.versions index 2919381..33b9c2c 100644 --- a/.versions +++ b/.versions @@ -22,7 +22,7 @@ geojson-utils@1.0.11 http@1.4.2 id-map@1.1.1 inter-process-messaging@0.1.1 -local-test:ostrio:mailer@2.5.0 +local-test:ostrio:mailer@3.0.0 logging@1.3.3 meteor@1.11.5 meteortesting:browser-tests@1.4.2 @@ -38,7 +38,7 @@ mongo-dev-server@1.1.0 mongo-id@1.0.8 npm-mongo@4.17.2 ordered-dict@1.1.0 -ostrio:mailer@2.5.0 +ostrio:mailer@3.0.0 promise@0.12.2 random@1.2.1 react-fast-refresh@0.2.8 diff --git a/index.cjs b/index.cjs index 629102b..ead2482 100644 --- a/index.cjs +++ b/index.cjs @@ -1,23 +1,17 @@ 'use strict'; -const JoSk = require('josk'); +const josk = require('josk'); const merge = require('deepmerge'); +const crypto = require('crypto'); -const noop = () => {}; -const _debug = (isDebug, ...args) => { +const debug = (isDebug, ...args) => { if (isDebug) { console.info.call(console, '[DEBUG] [mail-time]', `${new Date}`, ...args); } }; -const _logError = (...args) => { - console.error.call(console, '[ERROR] [mail-time]', `${new Date}`, ...args); -}; -const mongoErrorHandler = (error) => { - if (error) { - _logError('[mongoErrorHandler]:', error); - console.trace(); - } +const logError = (...args) => { + console.error.call(console, '[ERROR] [mail-time]', `${new Date}`, ...args); }; /** @@ -62,11 +56,572 @@ const ensureIndex = async (collection, keys, opts) => { await collection.createIndex(keys, opts); } } else { - _logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection._name}" collection`, { keys, opts, details: e }); + logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection._name || 'MongoDB'}" collection`, { keys, opts, details: e }); } } }; +/** Class representing MongoDB Queue for MailTime */ +class MongoQueue { + /** + * Create a MongoQueue instance + * @param {object} opts - configuration object + * @param {Db} opts.db - Required, Mongo's `Db` instance, like one returned from `MongoClient#db()` method + * @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application + */ + constructor (opts) { + this.name = 'mongo-queue'; + if (!opts || typeof opts !== 'object' || opts === null) { + throw new TypeError('[mail-time] Configuration object must be passed into MongoQueue constructor'); + } + + if (!opts.db) { + throw new Error('[mail-time] [MongoQueue] requires MongoDB database {db} option, like returned from `MongoClient.connect`'); + } + + this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : ''; + this.db = opts.db; + this.collection = opts.db.collection(`__mailTimeQueue__${this.prefix}`); + ensureIndex(this.collection, { uuid: 1 }, { background: false }); + ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, to: 1, sendAt: 1 }, { background: false }); + ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, sendAt: 1, tries: 1 }, { background: false }); + + // MongoDB Collection Schema: + // _id + // uuid {string} + // to {string|[string]} + // tries {number} - qty of send attempts + // sendAt {number} - When letter should be sent + // isSent {boolean} - Email status + // isCancelled {boolean} - `true` if email was cancelled before it was sent + // isFailed {boolean} - `true` if email has failed to send + // template {string} - Template for this email + // transport {number} - Last used transport + // concatSubject {string|boolean} - Email concatenation subject + // --- + // mailOptions {[object]} - Array of nodeMailer's `mailOptions` + // mailOptions.to {string|[string]} - [REQUIRED] + // mailOptions.from {string} + // mailOptions.text {string|boolean} + // mailOptions.html {string} + // mailOptions.subject {string} + // mailOptions.Other nodeMailer `sendMail` options... + } + + /** + * @async + * @memberOf MongoQueue + * @name ping + * @description Check connection to Storage + * @returns {Promise} + */ + async ping() { + if (!this.mailTimeInstance) { + return { + status: 'Service Unavailable', + code: 503, + statusCode: 503, + error: new Error('MailTime instance not yet assigned to {mailTimeInstance} of Queue Adapter context'), + }; + } + + try { + const ping = await this.db.command({ ping: 1 }); + if (ping?.ok === 1) { + return { + status: 'OK', + code: 200, + statusCode: 200, + }; + } + } catch (pingError) { + return { + status: 'Internal Server Error', + code: 500, + statusCode: 500, + error: pingError + }; + } + + return { + status: 'Service Unavailable', + code: 503, + statusCode: 503, + error: new Error('Service Unavailable') + }; + } + + /** + * @memberOf MongoQueue + * @name iterate + * @description iterate over queued tasks passing to `mailTimeInstance.___send` method + * @returns {void 0} + */ + async iterate() { + try { + const cursor = this.collection.find({ + isSent: false, + isFailed: false, + isCancelled: false, + sendAt: { + $lte: Date.now() + }, + tries: { + $lt: this.mailTimeInstance.maxTries + } + }, { + projection: { + _id: 1, + uuid: 1, + tries: 1, + template: 1, + transport: 1, + isSent: 1, + isFailed: 1, + isCancelled: 1, + mailOptions: 1, + concatSubject: 1, + } + }); + + while (await cursor.hasNext()) { + await this.mailTimeInstance.___send(await cursor.next()); + } + await cursor.close(); + } catch (iterateError) { + logError('[iterate] [while/await] [iterateError]', iterateError); + } + } + + /** + * @async + * @memberOf MongoQueue + * @name getPendingTo + * @description get queued task by `to` field (addressee) + * @param to {string} - email address + * @param sendAt {number} - timestamp + * @returns {Promise} + */ + async getPendingTo(to, sendAt) { + if (typeof to !== 'string' || typeof sendAt !== 'number') { + return null; + } + + return await this.collection.findOne({ + to: to, + isSent: false, + isFailed: false, + isCancelled: false, + sendAt: { + $lte: sendAt + } + }, { + projection: { + _id: 1, + to: 1, + uuid: 1, + tries: 1, + isSent: 1, + isFailed: 1, + isCancelled: 1, + mailOptions: 1, + } + }); + } + + /** + * @async + * @memberOf MongoQueue + * @name push + * @description push task to the queue/storage + * @param task {object} - task's object + * @returns {Promise} + */ + async push(task) { + if (typeof task !== 'object') { + return; + } + + if (task.sendAt instanceof Date) { + task.sendAt = +task.sendAt; + } + await this.collection.insertOne(task); + } + + /** + * @async + * @memberOf MongoQueue + * @name cancel + * @description cancel scheduled email + * @param uuid {string} - email's uuid + * @returns {Promise} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously + */ + async cancel(uuid) { + if (typeof uuid !== 'string') { + return false; + } + + const task = await this.collection.findOne({ uuid }, { + projection: { + _id: 1, + uuid: 1, + isSent: 1, + isCancelled: 1, + } + }); + + if (!task || task.isSent === true || task.isCancelled === true) { + return false; + } + + if (!this.mailTimeInstance.keepHistory) { + return await this.remove(task); + } + + return await this.update(task, { + isCancelled: true, + }); + } + + /** + * @async + * @memberOf MongoQueue + * @name remove + * @description remove task from queue + * @param task {object} - task's object + * @returns {Promise} returns `true` if removed or `false` if not found + */ + async remove(task) { + if (typeof task !== 'object') { + return false; + } + + return (await this.collection.deleteOne({ _id: task._id }))?.deletedCount >= 1; + } + + /** + * @async + * @memberOf MongoQueue + * @name update + * @description remove task from queue + * @param task {object} - task's object + * @param updateObj {object} - fields with new values to update + * @returns {Promise} returns `true` if updated or `false` if not found or no changes was made + */ + async update(task, updateObj) { + if (typeof task !== 'object' || typeof updateObj !== 'object') { + return false; + } + + return (await this.collection.updateOne({ + _id: task._id + }, { + $set: updateObj + }))?.modifiedCount >= 1; + } +} + +/** Class representing Redis Queue for MailTime */ +class RedisQueue { + /** + * Create a RedisQueue instance + * @param {object} opts - configuration object + * @param {RedisClient} opts.client - Required, Redis'es `RedisClient` instance, like one returned from `await redis.createClient().connect()` method + * @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application + */ + constructor (opts) { + this.name = 'redis-queue'; + if (!opts || typeof opts !== 'object' || opts === null) { + throw new TypeError('[mail-time] Configuration object must be passed into RedisQueue constructor'); + } + + if (!opts.client) { + throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method'); + } + + this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : 'default'; + this.uniqueName = `mailtime:${this.prefix}`; + this.client = opts.client; + + // 3 types of keys are stored in Redis: + // 'letter' - JSON with email details + // 'sendat' - Timestamp used to scan/find/iterate over scheduled emails + // 'concatletter' — uuid of "pending" email for concatenation used when {concatEmails: true} + + // Stored JSON structure: + // to {string|[string]} + // uuid {string} + // tries {number} - qty of send attempts + // sendAt {number} - When letter should be sent + // isSent {boolean} - Email status + // isCancelled {boolean} - `true` if email was cancelled before it was sent + // isFailed {boolean} - `true` if email has failed to send + // template {string} - Template for this email + // transport {number} - Last used transport + // concatSubject {string|boolean} - Email concatenation subject + // --- + // mailOptions {[object]} - Array of nodeMailer's `mailOptions` + // mailOptions.to {string|[string]} - [REQUIRED] + // mailOptions.from {string} + // mailOptions.text {string|boolean} + // mailOptions.html {string} + // mailOptions.subject {string} + // mailOptions.Other nodeMailer `sendMail` options... + } + + /** + * @async + * @memberOf RedisQueue + * @name ping + * @description Check connection to Storage + * @returns {Promise} + */ + async ping() { + if (!this.mailTimeInstance) { + return { + status: 'Service Unavailable', + code: 503, + statusCode: 503, + error: new Error('MailTime instance not yet assigned to {mailTimeInstance} of Queue Adapter context'), + }; + } + + try { + const ping = await this.client.ping(); + if (ping === 'PONG') { + return { + status: 'OK', + code: 200, + statusCode: 200, + }; + } + } catch (pingError) { + return { + status: 'Internal Server Error', + code: 500, + statusCode: 500, + error: pingError + }; + } + + return { + status: 'Service Unavailable', + code: 503, + statusCode: 503, + error: new Error('Service Unavailable') + }; + } + + /** + * @memberOf RedisQueue + * @name iterate + * @description iterate over queued tasks passing to `mailTimeInstance.___send` method + * @returns {void 0} + */ + async iterate() { + try { + const now = Date.now(); + const cursor = this.client.scanIterator({ + TYPE: 'string', + MATCH: this.__getKey('*', 'sendat'), + COUNT: 9999, + }); + + for await (const sendatKey of cursor) { + if (parseInt(await this.client.get(sendatKey)) <= now) { + await this.mailTimeInstance.___send(JSON.parse(await this.client.get(this.__getKey(sendatKey.split(':')[3])))); + } + } + } catch (iterateError) { + logError('[iterate] [for/await] [iterateError]', iterateError); + } + } + + /** + * @async + * @memberOf RedisQueue + * @name getPendingTo + * @description get queued task by `to` field (addressee) + * @param to {string} - email address + * @param sendAt {number} - timestamp + * @returns {Promise} + */ + async getPendingTo(to, sendAt) { + if (typeof to !== 'string' || typeof sendAt !== 'number') { + return null; + } + + const concatKey = this.__getKey(to, 'concatletter'); + let exists = await this.client.exists(concatKey); + if (!exists) { + return null; + } + + const uuid = await this.client.get(concatKey); + const letterKey = this.__getKey(uuid, 'letter'); + exists = await this.client.exists(letterKey); + if (!exists) { + return null; + } + + const task = JSON.parse(await this.client.get(letterKey)); + if (!task || task.isSent === true || task.isCancelled === true || task.isFailed === true) { + return null; + } + + return task; + } + + /** + * @async + * @memberOf RedisQueue + * @name push + * @description push task to the queue/storage + * @param task {object} - task's object + * @returns {Promise} + */ + async push(task) { + if (typeof task !== 'object') { + return; + } + + if (task.sendAt instanceof Date) { + task.sendAt = +task.sendAt; + } + + await this.client.set(this.__getKey(task.uuid, 'letter'), JSON.stringify(task)); + await this.client.set(this.__getKey(task.uuid, 'sendat'), `${task.sendAt}`); + if (task.to) { + await this.client.set(this.__getKey(task.to, 'concatletter'), task.uuid, { + PXAT: task.sendAt - 128 + }); + } + } + + /** + * @async + * @memberOf RedisQueue + * @name cancel + * @description cancel scheduled email + * @param uuid {string} - email's uuid + * @returns {Promise} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously + */ + async cancel(uuid) { + if (typeof uuid !== 'string') { + return false; + } + + await this.client.del(this.__getKey(uuid, 'sendat')); + const letterKey = this.__getKey(uuid, 'letter'); + const exists = await this.client.exists(letterKey); + if (!exists) { + return false; + } + + const task = JSON.parse(await this.client.get(letterKey)); + if (!task || task.isSent === true || task.isCancelled === true) { + return false; + } + + if (!this.mailTimeInstance.keepHistory) { + return await this.remove(task); + } + + return await this.update(task, { + isCancelled: true, + }); + } + + /** + * @async + * @memberOf RedisQueue + * @name remove + * @description remove task from queue + * @param task {object} - task's object + * @returns {Promise} returns `true` if removed or `false` if not found + */ + async remove(task) { + if (typeof task !== 'object' || typeof task.uuid !== 'string') { + return false; + } + + const letterKey = this.__getKey(task.uuid, 'letter'); + const exists = await this.client.exists(letterKey); + if (!exists) { + return false; + } + + await this.client.del([ + letterKey, + this.__getKey(task.uuid, 'sendat'), + ]); + return true; + } + + /** + * @async + * @memberOf RedisQueue + * @name update + * @description remove task from queue + * @param task {object} - task's object + * @param updateObj {object} - fields with new values to update + * @returns {Promise} returns `true` if updated or `false` if not found or no changes was made + */ + async update(task, updateObj) { + if (typeof task !== 'object' || typeof task.uuid !== 'string' || typeof updateObj !== 'object') { + return false; + } + + const letterKey = this.__getKey(task.uuid, 'letter'); + + try { + const exists = await this.client.exists(letterKey); + if (!exists) { + return false; + } + const updatedTask = { ...task, ...updateObj }; + await this.client.set(letterKey, JSON.stringify(updatedTask)); + + const sendatKey = this.__getKey(task.uuid, 'sendat'); + if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) { + await this.client.del(sendatKey); + } else if (task.sendAt) { + await this.client.set(sendatKey, `${+task.sendAt}`); + } + return true; + } catch (opError) { + logError('[update] [try/catch] [opError]', opError); + return false; + } + } + + + /** + * @memberOf RedisQueue + * @name __getKey + * @description helper to generate scoped key + * @param uuid {string} - letter's uuid + * @param type {string} - "letter" or "sendat" or "concatletter" + * @returns {string} returns key used by Redis + */ + __getKey(uuid, type = 'letter') { + if (!this.__keyTypes.includes(type)) { + throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`); + } + return `${this.uniqueName}:${type}:${uuid}`; + } + + /** + * @memberOf RedisQueue + * @name __keyTypes + * @description list of supported key type + * @returns {string} returns key used by Redis + */ + __keyTypes = ['letter', 'sendat', 'concatletter']; +} + +const noop = () => {}; + /** * Check if entities of various types are equal * including edge cases like unordered Objects and Array @@ -151,38 +706,80 @@ let DEFAULT_TEMPLATE = ' /** Class of MailTime */ class MailTime { + /** + * Create a MailTime instance + * @param {object} opts - configuration object + * @param {RedisQueue|MongoQueue|CustomQueue} opts.queue - Queue Storage Driver instance + * @param {string} [opts.type] - "server" or "client" type of MailTime instance + * @param {function} [opts.from] - A function returning *String* for `from` header, format: `"MyApp" ` + * @param {[object]} [opts.transports] - An array of `nodemailer`'s transports, returned from `nodemailer.createTransport({})`; Required for `{type: 'server'}` + * @param {string} [opts.strategy] - `backup` or `balancer` + * @param {number} [opts.failsToNext] - After how many failed "send attempts" switch to the next transport? Applied only for `backup` strategy, default - `4` + * @param {number} [opts.retries] - How many times resend failed emails + * @param {number} [opts.retryDelay] - Interval in milliseconds between send re-tries + * @param {boolean} [opts.keepHistory] - Keep queue task as it is in the database + * @param {boolean} [opts.concatEmails] - Concatenate email by `to` field, default - `false` + * @param {string} [opts.concatSubject] - Email subject used in concatenated email, default - `Multiple notifications` + * @param {string} [opts.concatDelimiter] - HTML or plain string delimiter used between concatenated email, default - `
` + * @param {number} [opts.concatDelay] - HTML or plain string delimiter used between concatenated email, default - `
` + * @param {number} [opts.revolvingInterval] - Interval in *milliseconds* in between queue checks, default - `256` + * @param {object|RedisAdapter|MongoAdapter|CustomAdapter} [opts.josk.adapter] - Interval in milliseconds between send re-tries + * @param {string} [opts.josk.adapter.type] - One of `mongo` or `redis` + * @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 + */ constructor (opts) { if (!opts || typeof opts !== 'object' || opts === null) { throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor'); } - if (!opts.db) { - throw new Error('[mail-time] MongoDB database {db} option is required, like returned from `MongoClient.connect`'); + if (!opts.queue || typeof opts.queue !== 'object') { + throw new Error('[mail-time] {queue} option is required', { + description: 'MailTime requires MongoQueue, RedisQueue, or CustomQueue to connect to an intermediate database' + }); + } + + this.queue = opts.queue; + this.queue.mailTimeInstance = this; + const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel']; + + for (let i = queueMethods.length - 1; i >= 0; i--) { + if (typeof this.queue[queueMethods[i]] !== 'function') { + throw new Error(`{queue} instance is missing {${queueMethods[i]}} method that is required!`); + } } - this.callbacks = {}; - this.type = (!opts.type || (opts.type !== 'client' && opts.type !== 'server')) ? 'server' : opts.type; this.debug = (opts.debug !== true) ? false : true; - this.prefix = opts.prefix || ''; - this.maxTries = ((opts.maxTries && !isNaN(opts.maxTries)) ? parseInt(opts.maxTries) : 59) + 1; - this.interval = ((opts.interval && !isNaN(opts.interval)) ? parseInt(opts.interval) : 60) * 1000; - this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}'; - this.zombieTime = opts.zombieTime || 32786; - this.keepHistory = opts.keepHistory || false; + this._debug = (...args) => { + debug(this.debug, ...args); + }; - this.revolvingInterval = opts.revolvingInterval || 1536; - this.minRevolvingDelay = opts.minRevolvingDelay || 512; - this.maxRevolvingDelay = opts.maxRevolvingDelay || 2048; + this.type = (typeof opts.type !== 'string' || (opts.type !== 'client' && opts.type !== 'server')) ? 'server' : opts.type; + this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : ''; - if (this.interval < 2048 || isNaN(this.interval)) { - this.interval = 3072; + if (typeof opts.retries === 'number') { + this.maxTries = opts.retries + 1; + } else if (typeof opts.maxTries === 'number') { + this.maxTries = (opts.maxTries < 1) ? 1 : 0; + } else { + this.maxTries = 60; } - if (this.zombieTime < 8192 || isNaN(this.zombieTime)) { - this.zombieTime = 8192; + if (typeof opts.retryDelay === 'number') { + this.retryDelay = opts.retryDelay; + } else if (typeof opts.interval === 'number') { + this.retryDelay = opts.interval * 1000; + } else { + this.retryDelay = 60000; } - this.failsToNext = (opts.failsToNext && !isNaN(opts.failsToNext)) ? parseInt(opts.failsToNext) : 4; + this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}'; + this.keepHistory = (typeof opts.keepHistory === 'boolean') ? opts.keepHistory : false; + this.onSent = opts.onSent || noop; + this.onError = opts.onError || noop; + + this.revolvingInterval = opts.revolvingInterval || 1536; + + this.failsToNext = (typeof opts.failsToNext === 'number') ? opts.failsToNext : 4; this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup'; this.transports = opts.transports || []; this.transport = 0; @@ -203,348 +800,247 @@ class MailTime { this.concatEmails = (opts.concatEmails !== true) ? false : true; this.concatSubject = (opts.concatSubject && typeof opts.concatSubject === 'string') ? opts.concatSubject : 'Multiple notifications'; this.concatDelimiter = (opts.concatDelimiter && typeof opts.concatDelimiter === 'string') ? opts.concatDelimiter : '
'; - this.concatThrottling = ((opts.concatThrottling && !isNaN(opts.concatThrottling)) ? parseInt(opts.concatThrottling) : 60) * 1000; - if (this.concatThrottling < 2048) { - this.concatThrottling = 3072; - } - - _debug(this.debug, 'DEBUG ON {debug: true}'); - _debug(this.debug, `INITIALIZING [type: ${this.type}]`); - _debug(this.debug, `INITIALIZING [strategy: ${this.strategy}]`); - _debug(this.debug, `INITIALIZING [prefix: ${this.prefix}]`); - _debug(this.debug, `INITIALIZING [maxTries: ${this.maxTries}]`); - _debug(this.debug, `INITIALIZING [failsToNext: ${this.failsToNext}]`); - - if (opts.type === 'server' && (this.transports.constructor !== Array || !this.transports.length)) { - throw new Error('[mail-time] transports is required and must be an Array, like returned from `nodemailer.createTransport`'); + if (typeof opts.concatDelay === 'number') { + this.concatDelay = opts.concatDelay; + } else if (typeof opts.concatThrottling === 'number') { + this.concatDelay = opts.concatThrottling * 1000; + } else { + this.concatDelay = 60000; } - this.collection = opts.db.collection(`__mailTimeQueue__${this.prefix}`); - ensureIndex(this.collection, { isSent: 1, to: 1, sendAt: 1 }); - ensureIndex(this.collection, { isSent: 1, sendAt: 1, tries: 1 }, { background: true }); - - // MongoDB Collection Schema: - // _id - // to {String|[String]} - // tries {Number} - qty of send attempts - // sendAt {Date} - When letter should be sent - // isSent {Boolean} - Email status - // template {String} - Template for this email - // transport {Number} - Last used transport - // concatSubject {String|Boolean} - Email concatenation subject - // --- - // mailOptions {[Object]} - Array of nodeMailer's `mailOptions` - // mailOptions.to {String|Array} - [REQUIRED] - // mailOptions.from {String} - // mailOptions.text {String|Boolean} - // mailOptions.html (String) - // mailOptions.subject {String} - // mailOptions.Other nodeMailer `sendMail` options... - + this._debug('DEBUG ON {debug: true}'); + this._debug(`INITIALIZING [type: ${this.type}]`); + this._debug(`INITIALIZING [strategy: ${this.strategy}]`); + this._debug(`INITIALIZING [josk.adapter: ${opts?.josk?.adapter}]`); + this._debug(`INITIALIZING [prefix: ${this.prefix}]`); + this._debug(`INITIALIZING [retries: ${this.retries}]`); + this._debug(`INITIALIZING [failsToNext: ${this.failsToNext}]`); + this._debug(`INITIALIZING [onError: ${this.onError}]`); + this._debug(`INITIALIZING [onSent: ${this.onSent}]`); + + /** SERVER-SPECIFIC CHECKS AND CONFIG */ if (this.type === 'server') { - const scheduler = new JoSk({ - db: opts.db, - debug: this.debug, - prefix: `mailTimeQueue${this.prefix}`, - resetOnInit: false, - zombieTime: this.zombieTime, - minRevolvingDelay: this.minRevolvingDelay, - maxRevolvingDelay: this.maxRevolvingDelay - }); - - scheduler.setInterval(this.___send.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`); - } - } - - static get Template() { - return DEFAULT_TEMPLATE; - } - - static set Template(newVal) { - DEFAULT_TEMPLATE = newVal; - } - - ___compileMailOpts(transport, task) { - let _mailOpts = {}; - - if (!transport) { - throw new Error('[mail-time] [sendMail] [___compileMailOpts] transport not available or misconfiguration is in place!'); - } + if (this.transports.constructor !== Array || !this.transports.length) { + throw new Error('[mail-time] {transports} is required for {type: "server"} and must be an Array, like returned from `nodemailer.createTransport`'); + } - if (transport._options && typeof transport._options === 'object' && transport._options !== null && transport._options.mailOptions) { - _mailOpts = merge(_mailOpts, transport._options.mailOptions); - } + if (typeof opts.josk !== 'object') { + throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}'); + } - if (transport.options && typeof transport.options === 'object' && transport.options !== null && transport.options.mailOptions) { - _mailOpts = merge(_mailOpts, transport.options.mailOptions); - } + if (typeof opts.josk.adapter !== 'object') { + throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class'); + } - _mailOpts = merge(_mailOpts, { - html: '', - text: '', - subject: '' - }); + this.josk = { ...opts.josk }; - for (let i = 0; i < task.mailOptions.length; i++) { - if (task.mailOptions[i].html) { - if (task.mailOptions.length > 1) { - _mailOpts.html += this.___render(this.concatDelimiter, task.mailOptions[i]) + this.___render(task.mailOptions[i].html, task.mailOptions[i]); - } else { - _mailOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]); + if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'mongo') { + if (!opts.josk.adapter.db) { + throw new Error('[mail-time] {josk.adapter.db} option required for {josk.adapter.type: "mongo"}'); } - delete task.mailOptions[i].html; + this.josk.adapter = new josk.MongoAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter }); } - if (task.mailOptions[i].text) { - if (task.mailOptions.length > 1) { - _mailOpts.text += '\r\n' + this.___render(task.mailOptions[i].text, task.mailOptions[i]); - } else { - _mailOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]); + if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'redis') { + if (!opts.josk.adapter.client) { + throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "redis"}'); } - delete task.mailOptions[i].text; + this.josk.adapter = new josk.RedisAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter }); } - _mailOpts = merge(_mailOpts, task.mailOptions[i]); - } + this.josk.minRevolvingDelay = (typeof opts.josk.minRevolvingDelay === 'number') ? opts.josk.minRevolvingDelay : 512; + this.josk.maxRevolvingDelay = (typeof opts.josk.maxRevolvingDelay === 'number') ? opts.josk.maxRevolvingDelay : 2048; + this.josk.zombieTime = (typeof opts.josk.zombieTime === 'number') ? opts.josk.zombieTime : 32786; - if (_mailOpts.html && (task.template || this.template)) { - _mailOpts.html = this.___render((task.template || this.template), _mailOpts); - } + this.scheduler = new josk.JoSk({ + debug: this.debug, + ...this.josk, + }); - if (task.mailOptions.length > 1) { - _mailOpts.subject = task.concatSubject || this.concatSubject || _mailOpts.subject; - } + process.nextTick(async () => { + if ((await this.scheduler.ping()).status !== 'OK') { + throw new Error('[mail-time] [MailTime#ping] can not connect to storage, make sure it is available and properly configured'); + } + }); - if (!_mailOpts.from && this.from) { - _mailOpts.from = this.from(transport); + this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`); } + } + + static get Template() { + return DEFAULT_TEMPLATE; + } - return _mailOpts; + static set Template(newVal) { + DEFAULT_TEMPLATE = newVal; } /** + * @async * @memberOf MailTime - * @name ___send - * @param ready {Function} - See JoSk NPM package - * @returns {void} + * @name ping + * @description Check package readiness and connection to Storage + * @returns {Promise} + * @throws {mix} */ - ___send(ready) { - const cursor = this.collection.find({ - isSent: false, - sendAt: { - $lte: new Date() - }, - tries: { - $lt: this.maxTries - } - }, { - projection: { - _id: 1, - tries: 1, - template: 1, - transport: 1, - mailOptions: 1, - concatSubject: 1 - } - }); - - cursor.forEach(async (task) => { - try { - await this.collection.updateOne({ - _id: task._id - }, { - $set: { - isSent: true - }, - $inc: { - tries: 1 - } - }); - - let transport; - let transportIndex; - if (this.strategy === 'balancer') { - this.transport = this.transport + 1; - if (this.transport >= this.transports.length) { - this.transport = 0; - } - transportIndex = this.transport; - transport = this.transports[transportIndex]; - } else { - transportIndex = task.transport; - transport = this.transports[transportIndex]; - } - - const _mailOpts = this.___compileMailOpts(transport, task); - - _debug(this.debug, '[sendMail] [sending] To:', _mailOpts.to); - transport.sendMail(_mailOpts, (error, info) => { - if (error) { - this.___handleError(task, error, info); - return; - } - - if (info.accepted && !info.accepted.length) { - this.___handleError(task, 'Message not accepted or Greeting never received', info); - return; - } - - _debug(this.debug, `email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, _mailOpts.to); - - if (!this.keepHistory) { - this.collection.deleteOne({ - _id: task._id - }).catch(mongoErrorHandler); - } - - this.___triggerCallbacks(void 0, task, info); - return; - }); - } catch (e) { - _logError('Exception during runtime:', e); - this.___handleError(task, e, {}); - } - }).catch((forEachError) => { - _logError('[___send] [forEach] [forEachError]', forEachError); - }).finally(() => { - ready(); - cursor.close(); - }); + async ping() { + this._debug('[ping]'); + const schedulerPing = await this.scheduler.ping(); + if (schedulerPing.status !== 'OK') { + return schedulerPing; + } + return await this.queue.ping(); } /** * @memberOf MailTime * @name send * @description alias of `sendMail` - * @returns {void} + * @returns {Promise} uuid of the email */ - send(opts, callback) { - this.sendMail(opts, callback); + async send(opts) { + this._debug('[send]', opts); + return await this.sendMail(opts); } /** + * @async * @memberOf MailTime * @name sendMail - * @param opts {Object} - Letter options with next properties: - * @param opts.sendAt {Date} - When email should be sent - * @param opts.template {String} - Template string - * @param opts[key] {mix} - Other MailOptions according to NodeMailer lib documentation - * @param callback {Function} - [OPTIONAL] Callback function - * @returns {void} + * @description add email to the queue or append to existing letter if {concatEmails: true} + * @param opts {object} - email options + * @param opts.sendAt {Date|number} - When email should be sent + * @param opts.template {string} - Email-specific template + * @param opts[key] {mix} - Other NodeMailer's options + * @returns {Promise} uuid of the email + * @throws {Error} */ - sendMail(opts = {}, callback = noop) { - _debug(this.debug, '[sendMail] [attempt] To:', opts.to); + async sendMail(opts = {}) { + this._debug('[sendMail]', opts); if (!opts.html && !opts.text) { throw new Error('`html` nor `text` field is presented, at least one of those fields is required'); } - if (!opts.sendAt || Object.prototype.toString.call(opts.sendAt) !== '[object Date]') { - opts.sendAt = new Date(); + let sendAt = opts.sendAt; + if (!sendAt) { + sendAt = Date.now(); } - if (typeof opts.template !== 'string') { - opts.template = false; + if (sendAt instanceof Date) { + sendAt = +sendAt; } - if (typeof opts.concatSubject !== 'string') { - opts.concatSubject = false; + if (typeof sendAt !== 'number') { + sendAt = Date.now(); } - let _sendAt = opts.sendAt; - const _template = opts.template; - const _concatSubject = opts.concatSubject; - delete opts.sendAt; - delete opts.template; - delete opts.concatSubject; + let template = opts.template; + if (typeof template !== 'string') { + template = false; + } - if (typeof opts.to !== 'string' && (!(opts.to instanceof Array) || !opts.to.length)) { + let concatSubject = opts.concatSubject; + if (typeof concatSubject !== 'string') { + concatSubject = false; + } + + const mailOptions = { ...opts }; + delete mailOptions.sendAt; + delete mailOptions.template; + delete mailOptions.concatSubject; + + if (typeof mailOptions.to !== 'string' && (!(mailOptions.to instanceof Array) || !mailOptions.to.length)) { throw new Error('[mail-time] `mailOptions.to` is required and must be a string or non-empty Array'); } if (this.concatEmails) { - _sendAt = new Date(+_sendAt + this.concatThrottling); - this.collection.findOne({ - to: opts.to, - isSent: false, - sendAt: { - $lte: _sendAt - } - }, { - projection: { - _id: 1, - mailOptions: 1 - } - }).then((task) => { - if (task) { - const queue = task.mailOptions || []; - - for (let i = 0; i < queue.length; i++) { - if (equals(queue[i], opts)) { - return; - } - } + sendAt = sendAt + this.concatDelay; + const task = await this.queue.getPendingTo(mailOptions.to, sendAt); - queue.push(opts); - this.collection.updateOne({ - _id: task._id - }, { - $set: { - mailOptions: queue - } - }).then(() => { - const _id = task._id.toHexString(); - if (!this.callbacks[_id]) { - this.callbacks[_id] = []; - } - this.callbacks[_id].push(callback); - }).catch(mongoErrorHandler); - return; - } + if (task) { + const pendingMailOptions = task.mailOptions || []; - this.___addToQueue({ - sendAt: _sendAt, - template: _template, - mailOptions: opts, - concatSubject: _concatSubject - }, callback); - }).catch(mongoErrorHandler); + for (let i = 0; i < pendingMailOptions.length; i++) { + if (equals(pendingMailOptions[i], mailOptions)) { + return task.uuid; + } + } - return; + pendingMailOptions.push(opts); + await this.queue.update(task, { + mailOptions: pendingMailOptions + }); + return task.uuid; + } } - this.___addToQueue({ - sendAt: _sendAt, - template: _template, - mailOptions: opts, - concatSubject: _concatSubject - }, callback); + return await this.___addToQueue({ + sendAt, + template, + concatSubject, + mailOptions: mailOptions, + }); + } + + /** + * @memberOf MailTime + * @name cancel + * @description alias of `cancelMail` + * @returns {Promise} returns `true` if cancelled or `false` if not found was sent or was cancelled previously + */ + async cancel(uuid) { + this._debug('[cancel]', uuid); + return await this.cancelMail(uuid); + } - return; + /** + * @async + * @memberOf MailTime + * @name cancelMail + * @description remove email from the queue or mark as `isCancelled` + * @param uuid {string|Promise} - uuid returned from `send` or `sendMail` + * @returns {Promise} returns `true` if cancelled or `false` if not found was sent or was cancelled previously + */ + async cancelMail(uuid) { + this._debug('[cancelMail]', uuid); + if (typeof uuid === 'object' && uuid instanceof Promise) { + return await this.queue.cancel(await uuid); + } + return await this.queue.cancel(uuid); } /** + * @async * @memberOf MailTime * @name ___handleError - * @param task {Object} - Email task record form Mongo - * @param error {mix} - Error String/Object/Error - * @param info {Object} - Info object returned from nodemailer - * @returns {void} + * @description Handle runtime errors and pass to `onError` callback + * @param task {object} - Email task record form Storage + * @param error {mix} - Error String/Object/Error + * @param info {object} - Info object returned from nodemailer + * @returns {Promise} */ - ___handleError(task, error, info) { + async ___handleError(task, error, info) { + this._debug('[private handleError]', { task, error, info }); if (!task) { return; } if (task.tries >= this.maxTries) { + task.isSent = false; + task.isFailed = true; + if (!this.keepHistory) { - this.collection.deleteOne({ - _id: task._id - }).catch(mongoErrorHandler); + await this.queue.remove(task); + } else { + await this.queue.update(task, { + isSent: task.isSent, + isFailed: task.isFailed, + }); } - _debug(this.debug, `Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error); - this.___triggerCallbacks(error, task, info); + this._debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error); + this.onError(error, task, info); return; } @@ -557,61 +1053,57 @@ class MailTime { } } - this.collection.updateOne({ - _id: task._id - }, { - $set: { - isSent: false, - sendAt: new Date(Date.now() + this.interval), - transport: transportIndex - } - }).catch(mongoErrorHandler); + await this.queue.update(task, { + isSent: false, + sendAt: Date.now() + this.retryDelay, + transport: transportIndex, + }); - _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); + 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); } /** + * @async * @memberOf MailTime * @name ___addToQueue - * @param opts {Object} - Letter options with next properties: - * @param opts.sendAt {Date} - When email should be sent - * @param opts.template {String} - Email template - * @param opts.mailOptions {Object} - MailOptions according to NodeMailer lib - * @param opts.concatSubject {String} - Email subject used when sending multiple concatenated emails - * @param callback {Function} - [OPTIONAL] Callback function - * @returns {void} + * @description Prepare task's object and push to the queue + * @param opts {object} - Email options with next properties: + * @param opts.sendAt {number} - When email should be sent + * @param opts.template {string} - Email-specific template + * @param opts.mailOptions {object} - MailOptions according to NodeMailer lib + * @param opts.concatSubject {string} - Email subject used when sending concatenated email + * @returns {Promise} message uuid */ - ___addToQueue(opts, callback) { - _debug(this.debug, '[sendMail] [adding to queue] To:', opts.mailOptions.to); + async ___addToQueue(opts) { + this._debug('[private addToQueue]', opts); const task = { + uuid: crypto.randomUUID(), tries: 0, isSent: false, sendAt: opts.sendAt, + isFailed: false, template: opts.template, transport: this.transport, + isCancelled: false, mailOptions: [opts.mailOptions], - concatSubject: opts.concatSubject + concatSubject: opts.concatSubject, }; if (this.concatEmails) { task.to = opts.mailOptions.to; } - this.collection.insertOne(task).then((r) => { - const _id = r.insertedId.toHexString(); - if (!this.callbacks[_id]) { - this.callbacks[_id] = []; - } - this.callbacks[_id].push(callback); - }).catch(mongoErrorHandler); + await this.queue.push(task); + return task.uuid; } /** * @memberOf MailTime * @name ___render - * @param string {String} - Template with Spacebars/Blaze/Mustache-like placeholders - * @param replacements {Object} - Blaze/Mustache-like helpers Object - * @returns {String} + * @description Render templates + * @param string {string} - Template with Mustache-like placeholders + * @param replacements {object} - Blaze/Mustache-like helpers Object + * @returns {string} */ ___render(_string, replacements) { let i; @@ -638,21 +1130,156 @@ class MailTime { /** * @memberOf MailTime - * @name ___triggerCallbacks - * @param error {mix} - Error thrown during sending email - * @param task {Object} - Task record from mongodb - * @param info {Object} - Info object returned from NodeMailer's `sendMail` method + * @name ___compileMailOpts + * @description Run various checks, compile options, and render template + * @param transport {object} - Current transport + * @param info {object} - Info object returned from NodeMailer's `sendMail` method * @returns {void 0} */ - ___triggerCallbacks(error, task, info) { - const _id = task._id.toHexString(); - if (this.callbacks[_id] && this.callbacks[_id].length) { - this.callbacks[_id].forEach((cb, i) => { - cb(error, info, task?.mailOptions?.[i]); + ___compileMailOpts(transport, task) { + let compiledOpts = {}; + + if (!transport) { + throw new Error('[mail-time] [sendMail] [___compileMailOpts] {transport} is not available or misconfigured!'); + } + + if (transport._options && typeof transport._options === 'object' && transport._options !== null && transport._options.mailOptions) { + compiledOpts = merge(compiledOpts, transport._options.mailOptions); + } + + if (transport.options && typeof transport.options === 'object' && transport.options !== null && transport.options.mailOptions) { + compiledOpts = merge(compiledOpts, transport.options.mailOptions); + } + + compiledOpts = merge(compiledOpts, { + html: '', + text: '', + subject: '' + }); + + for (let i = 0; i < task.mailOptions.length; i++) { + if (task.mailOptions[i].html) { + if (task.mailOptions.length > 1) { + compiledOpts.html += this.___render(this.concatDelimiter, task.mailOptions[i]) + this.___render(task.mailOptions[i].html, task.mailOptions[i]); + } else { + compiledOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]); + } + delete task.mailOptions[i].html; + } + + if (task.mailOptions[i].text) { + if (task.mailOptions.length > 1) { + compiledOpts.text += '\r\n' + this.___render(task.mailOptions[i].text, task.mailOptions[i]); + } else { + compiledOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]); + } + delete task.mailOptions[i].text; + } + + compiledOpts = merge(compiledOpts, task.mailOptions[i]); + } + + if (compiledOpts.html && (task.template || this.template)) { + compiledOpts.html = this.___render((task.template || this.template), compiledOpts); + } + + if (task.mailOptions.length > 1) { + compiledOpts.subject = task.concatSubject || this.concatSubject || compiledOpts.subject; + } + + if (!compiledOpts.from && this.from) { + compiledOpts.from = this.from(transport); + } + + return compiledOpts; + } + + /** + * @async + * @memberOf MailTime + * @name ___send + * @description send email using nodemailer's transport + * @param task {object} - email's task object from Storage + * @returns {Promise} + */ + async ___send(task) { + this._debug('[private send]', task); + try { + if (task.isSent === true || task.isFailed === true || task.isCancelled === true) { + return; + } + + task.tries++; + const isUpdated = await this.queue.update(task, { + isSent: true, + tries: task.tries }); + + if (!isUpdated) { + logError('[private send] [queue.update] Not updated!'); + return; + } + + let transport; + let transportIndex; + if (this.strategy === 'balancer') { + this.transport = this.transport + 1; + if (this.transport >= this.transports.length) { + this.transport = 0; + } + transportIndex = this.transport; + transport = this.transports[transportIndex]; + } else { + transportIndex = task.transport; + transport = this.transports[transportIndex]; + } + + const compiledOpts = this.___compileMailOpts(transport, task); + + await new Promise((resolve) => { + transport.sendMail(compiledOpts, async (error, info) => { + this._debug('[private send] [sending]', { error, info }); + if (error) { + await this.___handleError(task, error, info); + resolve(); + return; + } + + if (info.accepted && !info.accepted.length) { + await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info); + resolve(); + return; + } + + this._debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to); + + if (!this.keepHistory) { + await this.queue.remove(task); + } + + task.isSent = true; + this.onSent(task, info); + resolve(); + }); + }); + } catch (e) { + logError('Exception during runtime:', e); + this.___handleError(task, e, {}); } - delete this.callbacks[_id]; + } + + /** + * @memberOf MailTime + * @name ___iterate + * @description Iterate over queued tasks + * @returns {Promise} + */ + async ___iterate() { + this._debug('[private iterate]'); + return await this.queue.iterate(); } } -module.exports = MailTime; +exports.MailTime = MailTime; +exports.MongoQueue = MongoQueue; +exports.RedisQueue = RedisQueue; diff --git a/package-lock.json b/package-lock.json index 1ee41eb..421d8d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,28 +1,29 @@ { "name": "mail-time", - "version": "1.4.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mail-time", - "version": "1.4.0", + "version": "3.0.0", "license": "BSD-3-Clause", "dependencies": { "deepmerge": "^4.3.1", - "josk": "^3.1.1" + "josk": "^5.0.0" }, "devDependencies": { "bson": "^6.6.0", "bson-ext": "^4.0.3", - "chai": "^4.4.1", - "mocha": "^10.3.0", + "chai": "^5.1.0", + "mocha": "^10.4.0", "mongodb": "^6.5.0", "nodemailer": "^6.9.12", - "nodemailer-direct-transport": "^3.3.2" + "nodemailer-direct-transport": "^3.3.2", + "redis": "^4.6.13" }, "engines": { - "node": ">=14.1.0" + "node": ">=14.20.0" } }, "node_modules/@aws-crypto/crc32": { @@ -787,6 +788,65 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "dev": true, + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.5.14", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.14.tgz", + "integrity": "sha512-YGn0GqsRBFUQxklhY7v562VMOP0DcmlrHHs3IV1mFE3cbxe31IITUkqhBcIhVSI/2JqtWAJXg5mjV4aU+zD0HA==", + "dev": true, + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "dev": true, + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.6.tgz", + "integrity": "sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==", + "dev": true, + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.6.tgz", + "integrity": "sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==", + "dev": true, + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.5.tgz", + "integrity": "sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==", + "dev": true, + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@smithy/abort-controller": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.2.0.tgz", @@ -1514,12 +1574,12 @@ "dev": true }, "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "engines": { - "node": "*" + "node": ">=12" } }, "node_modules/balanced-match": { @@ -1703,21 +1763,19 @@ } }, "node_modules/chai": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", - "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.0.tgz", + "integrity": "sha512-kDZ7MZyM6Q1DhR9jy7dalKohXQ2yrlXkk59CR52aRKxJrobmlBNqnFQxX9xOX8w+4mz8SYlKJa/7D7ddltFXCw==", "dev": true, "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.0.8" + "assertion-error": "^2.0.1", + "check-error": "^2.0.0", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, "engines": { - "node": ">=4" + "node": ">=12" } }, "node_modules/chalk": { @@ -1749,15 +1807,12 @@ } }, "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.0.0.tgz", + "integrity": "sha512-tjLAOBHKVxtPoHe/SA7kNOMvhCRdCJ3vETdeY0RuAc9popf+hyaSV6ZEg9hr4cpWF7jmo/JSWEnLDrnijS9Tog==", "dev": true, - "dependencies": { - "get-func-name": "^2.0.2" - }, "engines": { - "node": "*" + "node": ">= 16" } }, "node_modules/chokidar": { @@ -1848,6 +1903,15 @@ "node": ">=8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -1935,13 +1999,10 @@ } }, "node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.1.tgz", + "integrity": "sha512-nwQCf6ne2gez3o1MxWifqkciwt0zhl0LO1/UwVu4uMBuPmflWM4oQ70XMqHqnBJA+nhzncaqL9HVL6KkHJ28lw==", "dev": true, - "dependencies": { - "type-detect": "^4.0.0" - }, "engines": { "node": ">=6" } @@ -2144,6 +2205,15 @@ "wide-align": "^1.1.0" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2384,9 +2454,9 @@ "dev": true }, "node_modules/josk": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/josk/-/josk-3.1.1.tgz", - "integrity": "sha512-1ZdxA5Kth2hYb6wW0c8XdvGftnkj/l7vpaRKdsP5O7uV3AmmBfwYv0eZTL84mZlVwc57or9u9aTMEsRUXTr61w==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/josk/-/josk-5.0.0.tgz", + "integrity": "sha512-hD82IqX1kCTzetZn9sk8jOiMW7emArlYclsykk4+BOSkQlnfRN01tyAoNsOO9eYkkUg3rcGcui1h4A/iwkHp2w==", "engines": { "node": ">=14.20.0" } @@ -2443,9 +2513,9 @@ } }, "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.0.tgz", + "integrity": "sha512-qKl+FrLXUhFuHUoDJG7f8P8gEMHq9NFS0c6ghXG1J0rldmZFQZoNVv/vyirE9qwCIhWZDsvEFd1sbFu3GvRQFg==", "dev": true, "dependencies": { "get-func-name": "^2.0.1" @@ -2494,9 +2564,9 @@ "dev": true }, "node_modules/mocha": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.3.0.tgz", - "integrity": "sha512-uF2XJs+7xSLsrmIvn37i/wnc91nw7XjOQB8ccyx5aEgdnohr7n+rEiZP23WkCYHjilR6+EboEnbq/ZQDz4LSbg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.4.0.tgz", + "integrity": "sha512-eqhGB8JKapEYcC4ytX/xrzKforgEc3j1pGlAXVy3eRwrtAy5/nIfT1SvgGzfN0XZZxeLq0aQWkOUAmqIJiv+bA==", "dev": true, "dependencies": { "ansi-colors": "4.1.1", @@ -2733,12 +2803,12 @@ } }, "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, "engines": { - "node": "*" + "node": ">= 14.16" } }, "node_modules/picomatch": { @@ -2865,6 +2935,23 @@ "node": ">=8.10.0" } }, + "node_modules/redis": { + "version": "4.6.13", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.13.tgz", + "integrity": "sha512-MHgkS4B+sPjCXpf+HfdetBwbRz6vCtsceTmw1pHNYJAsYxrfpOP6dz+piJWGos8wqG7qb3vj/Rrc5qOlmInUuA==", + "dev": true, + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.5.14", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.6", + "@redis/search": "1.1.6", + "@redis/time-series": "1.0.5" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3152,15 +3239,6 @@ "node": "*" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/underscore": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz", @@ -3297,6 +3375,12 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/package.js b/package.js index 977d80d..431966c 100644 --- a/package.js +++ b/package.js @@ -1,14 +1,14 @@ Package.describe({ name: 'ostrio:mailer', - version: '2.5.0', - summary: 'Bulletproof email queue on top of NodeMailer for a single and multi-server setups', + version: '3.0.0', + summary: '📮 Email queue extending NodeMailer with multi SMTP transports and horizontally scaled apps support', git: 'https://github.com/veliovgroup/mail-time', documentation: 'README.md' }); Package.onUse((api) => { Npm.depends({ - josk: '3.1.1', + josk: '5.0.0', deepmerge: '4.3.1', }); @@ -23,6 +23,7 @@ Package.onTest((api) => { nodemailer: '6.9.12', 'nodemailer-direct-transport': '3.3.2', chai: '4.4.1', + redis: '4.6.13', }); api.use(['ecmascript@0.16.8 || 0.16.8-beta300.0', 'mongo@1.6.19 || 2.0.0-beta300.0', 'meteortesting:mocha@2.1.0 || 3.1.0-beta300.0'], 'server'); diff --git a/package.json b/package.json index bf67220..225a480 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mail-time", - "version": "1.4.0", - "description": "Bulletproof email queue on top of NodeMailer for a single and multi-server setups", + "version": "3.0.0", + "description": "📮 Email queue extending NodeMailer with multi SMTP transports and horizontally scaled apps support", "main": "index.js", "type": "module", "exports": { @@ -63,19 +63,20 @@ "author": "dr.dimitru (https://veliovgroup.com)", "license": "BSD-3-Clause", "engines": { - "node": ">=14.1.0" + "node": ">=14.20.0" }, "dependencies": { "deepmerge": "^4.3.1", - "josk": "^3.1.1" + "josk": "^5.0.0" }, "devDependencies": { "bson": "^6.6.0", "bson-ext": "^4.0.3", - "chai": "^4.4.1", - "mocha": "^10.3.0", + "chai": "^5.1.0", + "mocha": "^10.4.0", "mongodb": "^6.5.0", "nodemailer": "^6.9.12", - "nodemailer-direct-transport": "^3.3.2" + "nodemailer-direct-transport": "^3.3.2", + "redis": "^4.6.13" } }