Comparing version 1.3.4 to 1.4.0
278
index.js
@@ -1,4 +0,5 @@ | ||
const JoSk = require('josk'); | ||
import JoSk from 'josk'; | ||
import merge from 'deepmerge'; | ||
const noop = () => {}; | ||
const merge = require('deepmerge'); | ||
const _debug = (isDebug, ...args) => { | ||
@@ -20,4 +21,58 @@ if (isDebug) { | ||
let equals; | ||
equals = (a, b) => { | ||
/** | ||
* Ensure (create) index on MongoDB collection, catch and log exception if thrown | ||
* @function ensureIndex | ||
* @param {Collection} collection - Mongo's driver Collection instance | ||
* @param {object} keys - Field and value pairs where the field is the index key and the value describes the type of index for that field | ||
* @param {object} opts - Set of options that controls the creation of the index | ||
* @returns {void 0} | ||
*/ | ||
const ensureIndex = async (collection, keys, opts) => { | ||
try { | ||
await collection.createIndex(keys, opts); | ||
} catch (e) { | ||
if (e.code === 85) { | ||
let indexName; | ||
const indexes = await collection.indexes(); | ||
for (const index of indexes) { | ||
let drop = true; | ||
for (const indexKey of Object.keys(keys)) { | ||
if (typeof index.key[indexKey] === 'undefined') { | ||
drop = false; | ||
break; | ||
} | ||
} | ||
for (const indexKey of Object.keys(index.key)) { | ||
if (typeof keys[indexKey] === 'undefined') { | ||
drop = false; | ||
break; | ||
} | ||
} | ||
if (drop) { | ||
indexName = index.name; | ||
break; | ||
} | ||
} | ||
if (indexName) { | ||
await collection.dropIndex(indexName); | ||
await collection.createIndex(keys, opts); | ||
} | ||
} else { | ||
_logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection._name}" collection`, { keys, opts, details: e }); | ||
} | ||
} | ||
}; | ||
/** | ||
* Check if entities of various types are equal | ||
* including edge cases like unordered Objects and Array | ||
* @function equals | ||
* @param {mix} a | ||
* @param {mix} b | ||
* @returns {boolean} | ||
*/ | ||
const equals = (a, b) => { | ||
let i; | ||
@@ -96,3 +151,4 @@ if (a === b) { | ||
module.exports = class MailTime { | ||
/** Class of MailTime */ | ||
export default class MailTime { | ||
constructor (opts) { | ||
@@ -168,13 +224,6 @@ if (!opts || typeof opts !== 'object' || opts === null) { | ||
this.collection = opts.db.collection(`__mailTimeQueue__${this.prefix}`); | ||
this.collection.createIndex({ isSent: 1, to: 1, sendAt: 1 }, (indexError) => { | ||
if (indexError) { | ||
_logError('[createIndex]', indexError); | ||
} | ||
}); | ||
this.collection.createIndex({ isSent: 1, sendAt: 1, tries: 1 }, { background: true }, (indexError) => { | ||
if (indexError) { | ||
_logError('[createIndex]', indexError); | ||
} | ||
}); | ||
// Schema: | ||
ensureIndex(this.collection, { isSent: 1, to: 1, sendAt: 1 }); | ||
ensureIndex(this.collection, { isSent: 1, sendAt: 1, tries: 1 }, { background: true }); | ||
// MongoDB Collection Schema: | ||
// _id | ||
@@ -304,68 +353,63 @@ // to {String|[String]} | ||
cursor.forEach((task) => { | ||
this.collection.updateOne({ | ||
_id: task._id | ||
}, { | ||
$set: { | ||
isSent: true | ||
}, | ||
$inc: { | ||
tries: 1 | ||
} | ||
}, (updateError) => { | ||
if (updateError) { | ||
this.___handleError(task, updateError, {}); | ||
} else { | ||
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]; | ||
cursor.forEach(async (task) => { | ||
try { | ||
await this.collection.updateOne({ | ||
_id: task._id | ||
}, { | ||
$set: { | ||
isSent: true | ||
}, | ||
$inc: { | ||
tries: 1 | ||
} | ||
}); | ||
try { | ||
const _mailOpts = this.___compileMailOpts(transport, task); | ||
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]; | ||
} | ||
_debug(this.debug, '[sendMail] [sending] To:', _mailOpts.to); | ||
transport.sendMail(_mailOpts, (error, info) => { | ||
if (error) { | ||
this.___handleError(task, error, info); | ||
return; | ||
} | ||
const _mailOpts = this.___compileMailOpts(transport, task); | ||
if (info.accepted && !info.accepted.length) { | ||
this.___handleError(task, 'Message not accepted or Greeting never received', info); | ||
return; | ||
} | ||
_debug(this.debug, '[sendMail] [sending] To:', _mailOpts.to); | ||
transport.sendMail(_mailOpts, (error, info) => { | ||
if (error) { | ||
this.___handleError(task, error, info); | ||
return; | ||
} | ||
_debug(this.debug, `email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, _mailOpts.to); | ||
if (this.keepHistory) { | ||
this.___triggerCallbacks(void 0, task, info); | ||
} else { | ||
this.collection.deleteOne({ _id: task._id }, () => { | ||
this.___triggerCallbacks(void 0, task, info); | ||
}); | ||
} | ||
if (info.accepted && !info.accepted.length) { | ||
this.___handleError(task, 'Message not accepted or Greeting never received', info); | ||
return; | ||
} | ||
return; | ||
}); | ||
} catch (e) { | ||
_logError('Exception during runtime:', e); | ||
this.___handleError(task, e, {}); | ||
_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); | ||
} | ||
} | ||
}); | ||
}, (forEachError) => { | ||
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(); | ||
if (forEachError) { | ||
_logError('[___send] [forEach] [forEachError]', forEachError); | ||
} | ||
}); | ||
@@ -424,2 +468,3 @@ } | ||
if (this.concatEmails) { | ||
_sendAt = new Date(+_sendAt + this.concatThrottling); | ||
this.collection.findOne({ | ||
@@ -429,3 +474,3 @@ to: opts.to, | ||
sendAt: { | ||
$lte: new Date(+_sendAt + this.concatThrottling) | ||
$lte: _sendAt | ||
} | ||
@@ -437,9 +482,3 @@ }, { | ||
} | ||
}, (findError, task) => { | ||
if (findError) { | ||
_debug(this.debug, 'something went wrong, can\'t send email to: ', opts.to, findError); | ||
callback(findError, void 0, task); | ||
return; | ||
} | ||
}).then((task) => { | ||
if (task) { | ||
@@ -455,3 +494,2 @@ const queue = task.mailOptions || []; | ||
queue.push(opts); | ||
this.collection.updateOne({ | ||
@@ -463,13 +501,12 @@ _id: task._id | ||
} | ||
}, (updateError) => { | ||
if (updateError) { | ||
_debug('something went wrong, can\'t send email to: ', task.mailOptions[0].to, updateError); | ||
callback(updateError, void 0, task); | ||
}).then(() => { | ||
const _id = task._id.toHexString(); | ||
if (!this.callbacks[_id]) { | ||
this.callbacks[_id] = []; | ||
} | ||
}); | ||
this.callbacks[_id].push(callback); | ||
}).catch(mongoErrorHandler); | ||
return; | ||
} | ||
_sendAt = new Date(+_sendAt + this.concatThrottling); | ||
this.___addToQueue({ | ||
@@ -481,4 +518,3 @@ sendAt: _sendAt, | ||
}, callback); | ||
return; | ||
}); | ||
}).catch(mongoErrorHandler); | ||
@@ -515,29 +551,30 @@ return; | ||
_id: task._id | ||
}, (deleteError) => { | ||
_debug(this.debug, `Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error, deleteError); | ||
this.___triggerCallbacks(error, task, info); | ||
}); | ||
}).catch(mongoErrorHandler); | ||
} | ||
} else { | ||
let transportIndex = task.transport; | ||
if (this.strategy === 'backup' && (task.tries % this.failsToNext) === 0) { | ||
++transportIndex; | ||
if (transportIndex > this.transports.length - 1) { | ||
transportIndex = 0; | ||
} | ||
_debug(this.debug, `Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error); | ||
this.___triggerCallbacks(error, task, info); | ||
return; | ||
} | ||
let transportIndex = task.transport; | ||
if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) { | ||
++transportIndex; | ||
if (transportIndex > this.transports.length - 1) { | ||
transportIndex = 0; | ||
} | ||
} | ||
this.collection.updateOne({ | ||
_id: task._id | ||
}, { | ||
$set: { | ||
isSent: false, | ||
sendAt: new Date(Date.now() + this.interval), | ||
transport: transportIndex | ||
} | ||
}, mongoErrorHandler); | ||
this.collection.updateOne({ | ||
_id: task._id | ||
}, { | ||
$set: { | ||
isSent: false, | ||
sendAt: new Date(Date.now() + this.interval), | ||
transport: transportIndex | ||
} | ||
}).catch(mongoErrorHandler); | ||
_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); | ||
} | ||
_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); | ||
} | ||
@@ -572,9 +609,3 @@ | ||
this.collection.insertOne(task, (insertError, r) => { | ||
if (insertError) { | ||
_logError('something went wrong, can\'t send email to: ', opts.mailOptions[0].to, insertError); | ||
callback(insertError, void 0, opts); | ||
return; | ||
} | ||
this.collection.insertOne(task).then((r) => { | ||
const _id = r.insertedId.toHexString(); | ||
@@ -585,4 +616,3 @@ if (!this.callbacks[_id]) { | ||
this.callbacks[_id].push(callback); | ||
return; | ||
}); | ||
}).catch(mongoErrorHandler); | ||
} | ||
@@ -632,3 +662,3 @@ | ||
this.callbacks[_id].forEach((cb, i) => { | ||
cb(error, info, task.mailOptions[i]); | ||
cb(error, info, task?.mailOptions?.[i]); | ||
}); | ||
@@ -638,2 +668,2 @@ } | ||
} | ||
}; | ||
} |
{ | ||
"name": "mail-time", | ||
"version": "1.3.4", | ||
"version": "1.4.0", | ||
"description": "Bulletproof email queue on top of NodeMailer for a single and multi-server setups", | ||
"main": "index.js", | ||
"type": "module", | ||
"exports": { | ||
"import": "./index.js", | ||
"require": "./index.cjs" | ||
}, | ||
"scripts": { | ||
"prepublishOnly": "rollup --config rollup.config.js", | ||
"test": "mocha ./test/npm.js" | ||
@@ -14,3 +20,7 @@ }, | ||
"bulletproof", | ||
"cluster", | ||
"email", | ||
"highload", | ||
"high load", | ||
"horizontal scaling", | ||
"mail", | ||
@@ -20,2 +30,4 @@ "mail-time", | ||
"mailtime", | ||
"microservice", | ||
"micro-service", | ||
"nodemailer", | ||
@@ -30,3 +42,7 @@ "queue", | ||
"bulletproof", | ||
"cluster", | ||
"email", | ||
"highload", | ||
"high load", | ||
"horizontal scaling", | ||
"mail", | ||
@@ -36,2 +52,4 @@ "mail-time", | ||
"mailtime", | ||
"microservice", | ||
"micro-service", | ||
"nodemailer", | ||
@@ -52,17 +70,17 @@ "queue", | ||
"engines": { | ||
"node": ">=8.9.0" | ||
"node": ">=14.1.0" | ||
}, | ||
"dependencies": { | ||
"deepmerge": "^4.2.2", | ||
"josk": "^3.0.2" | ||
"deepmerge": "^4.3.1", | ||
"josk": "^3.1.1" | ||
}, | ||
"devDependencies": { | ||
"bson": "^4.7.0", | ||
"bson": "^6.6.0", | ||
"bson-ext": "^4.0.3", | ||
"chai": "^4.3.6", | ||
"mocha": "^10.0.0", | ||
"mongodb": "^4.10.0", | ||
"nodemailer": "^6.7.8", | ||
"nodemailer-direct-transport": "^3.0.7" | ||
"chai": "^4.4.1", | ||
"mocha": "^10.3.0", | ||
"mongodb": "^6.5.0", | ||
"nodemailer": "^6.9.12", | ||
"nodemailer-direct-transport": "^3.3.2" | ||
} | ||
} |
111
README.md
[![support](https://img.shields.io/badge/support-GitHub-white)](https://github.com/sponsors/dr-dimitru) | ||
[![support](https://img.shields.io/badge/support-PayPal-white)](https://paypal.me/veliovgroup) | ||
<a href="https://ostr.io/info/built-by-developers-for-developers"> | ||
<img src="https://ostr.io/apple-touch-icon-60x60.png" height="20"> | ||
</a> | ||
<a href="https://ostr.io/info/built-by-developers-for-developers?ref=github-mail-time-repo-top"><img src="https://ostr.io/apple-touch-icon-60x60.png" height="20"></a> | ||
<a href="https://meteor-files.com/?ref=github-mail-time-repo-top"><img src="https://meteor-files.com/apple-touch-icon-60x60.png" height="20"></a> | ||
# MailTime | ||
"Mail-Time" is a micro-service package for mail queue, with *Server* and *Client* APIs. Build on top of the [`nodemailer`](https://github.com/nodemailer/nodemailer) package. | ||
"Mail-Time" is a micro-service package for mail queue, with *Server* and *Client* APIs. Build on top of the [`nodemailer`](https://github.com/nodemailer/nodemailer) package. Mail-Time made for single-server and horizontally scaled multi-server setups in mind. | ||
Every `MailTime` instance can get `type` configured as *Server* or *Client*. | ||
Every `MailTime` instance can have `type` configured as *Server* or *Client*. | ||
@@ -17,16 +16,16 @@ The main difference between *Server* and *Client* `type` is that the *Server* handles the queue and __sends__ email. While the *Client* only __adds__ emails into the queue. | ||
- [How it works?](https://github.com/veliovgroup/mail-time#how-it-works) | ||
- [With single SMTP](https://github.com/veliovgroup/mail-time#single-point-of-failure) | ||
- [With multiple SMTP](https://github.com/veliovgroup/mail-time#multiple-smtp-providers) | ||
- [As Micro-Service](https://github.com/veliovgroup/mail-time#cluster-issue) | ||
- [Features](https://github.com/veliovgroup/mail-time#features) | ||
- [Installation](https://github.com/veliovgroup/mail-time#installation) | ||
- [How it works?](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#how-it-works) | ||
- [With single SMTP](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#single-point-of-failure) | ||
- [With multiple SMTP](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#multiple-smtp-providers) | ||
- [For Clusters](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#sending-emails-from-cluster-of-servers) | ||
- [Features](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#features) | ||
- [Installation](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#installation) | ||
- [Meteor.js usage](https://github.com/veliovgroup/mail-time/blob/master/docs/meteor.md) | ||
- [Usage example](https://github.com/veliovgroup/mail-time#basic-usage) | ||
- [API](https://github.com/veliovgroup/mail-time#api) | ||
- [*Constructor*](https://github.com/veliovgroup/mail-time#new-mailtimeopts-constructor) | ||
- [`.send()`](https://github.com/veliovgroup/mail-time#sendmailopts--callback) | ||
- [Default Template](https://github.com/veliovgroup/mail-time#static-mailtimetemplate) | ||
- [Custom Templates](https://github.com/veliovgroup/mail-time#template-example) | ||
- [~92% tests coverage](https://github.com/veliovgroup/mail-time#testing) | ||
- [Usage example](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#basic-usage) | ||
- [API](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#api) | ||
- [*Constructor*](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#new-mailtimeopts-constructor) | ||
- [`.send()`](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#sendmailopts--callback) | ||
- [Default Template](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#static-mailtimetemplate) | ||
- [Custom Templates](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#template-example) | ||
- [~92% tests coverage](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#testing) | ||
@@ -37,3 +36,3 @@ ## Main features: | ||
- 📦 Two simple dependencies, written from scratch for top performance; | ||
- 🏢 Synchronize email queue across multiple servers; | ||
- 🏢 Synchronize email queue across multiple (horizontally scaled) servers; | ||
- 💪 Bulletproof design, built-in retries. | ||
@@ -47,3 +46,3 @@ | ||
Issue - classic solution with a single point of failure: | ||
Issue - mitigate a single point of failure: | ||
@@ -78,3 +77,3 @@ ```ascii | ||
Backup scheme with multiple SMTP providers | ||
Rotate or backup email transports by using multiple SMTP providers | ||
@@ -95,23 +94,41 @@ ```ascii | ||
### Cluster issue | ||
### Sending emails from cluster of servers | ||
It is common to create a "Cluster" of servers to balance the load and add a durability layer for horizontal scaling of quickly growing applications. | ||
It is common to have horizontally scaled "Cluster" of servers for load-balancing and for durability. | ||
Most modern application has scheduled or recurring emails. For example, once a day — with recent news and updates. It won't be an issue with a single server setup — the server would send emails at a daily interval via timer or CRON. While in "Cluster" implementation — each server will attempt to send the same email. In such cases, users will receive multiple emails with the same content. We built MailTime to address this and other similar issues. | ||
Most modern application has scheduled or recurring emails. For example, once a day — with recent news and updates. It won't be an issue with a single server setup — the server would send emails at a daily interval via timer or CRON. But in "Cluster" implementation — each server will attempt to send the same email. MailTime built to avoid sending the same email multiple times to a user from horizontally scaled applications. | ||
Here is how this issue is solved by using MailTime: | ||
For the maximum durability and agility each Application Server can run MailTime in the "Server" mode: | ||
```ascii | ||
|===================THE=CLUSTER===================| |=QUEUE=| | ||
| |----------| |----------| |----------| | | | |--------| | ||
| | App | | App | | App | | | |-->| SMTP 1 |------\ | ||
| | Server 1 | | Server 2 | | Server 3 | | | | |--------| \ | ||
| |-----\----| |----\-----| |----\-----| | | | |-------------| | ||
| \---------------\----------------\----------> | |--------| | ^_^ | | ||
| | | |-->| SMTP 2 |-->| Happy users | | ||
| Each "App Server" or "Cluster Node" | | | |--------| |-------------| | ||
| runs MailTime as a "Server" | | | / | ||
| for the maximum durability | | | |--------| / | ||
| | | |-->| SMTP 3 |-----/ | ||
| | | | |--------| | ||
|=================================================| |=======| | ||
``` | ||
To split roles MailTime can run on a dedicated machine as micro-service. This case is great for private email servers with implemented authentication via rDNS and PTR records: | ||
```ascii | ||
|===================THE=CLUSTER===================| |=QUEUE=| |===Mail=Time===| | ||
| |----------| |----------| |----------| | | | |=Micro-service=| |--------| | ||
| | App | | App | | App | | | | | |-->| SMTP 1 |------\ | ||
| | Server 1 | | Server 2 | | Server 3 | | | <-------- | |--------| \ | ||
| |-----\----| |----\-----| |----\-----| | | --------> | |-------------| | ||
| \---------------\----------------\----------> | | | |--------| | ^_^ | | ||
| Each of the "App Server" or "Cluster Node" | | | | |-->| SMTP 2 |-->| Happy users | | ||
| runs Mail Time as a Client which only puts | | | | | |--------| |-------------| | ||
| emails into the queue. Aside to "App Servers" | | | | | / | ||
| We suggest running Mail Time as a Micro-service | | | | | |--------| / | ||
| which will be responsible for making sure queue | | | | |-->| SMTP 3 |-----/ | ||
| has no duplicates and to actually send emails | | | | | |--------| | ||
| |----------| |----------| |----------| | | | | | |--------| | ||
| | App | | App | | App | | | | | Micro-service |-->| SMTP 1 |------\ | ||
| | Server 1 | | Server 2 | | Server 3 | | | | | running | |--------| \ | ||
| |-----\----| |----\-----| |----\-----| | | | | MailTime as | |-------------| | ||
| \---------------\----------------\----------> | | "Server" only | |--------| | ^_^ | | ||
| | | | | sending |-->| SMTP 2 |-->| Happy users | | ||
| Each "App Server" runs MailTime as | | | | emails | |--------| |-------------| | ||
| a "Client" only placing emails to the queue. | | <-------- | / | ||
| | | --------> | |--------| / | ||
| | | | | |-->| SMTP 3 |-----/ | ||
| | | | | | |--------| | ||
|=================================================| |=======| |===============| | ||
@@ -141,5 +158,8 @@ ``` | ||
```shell | ||
# for node@>=8.9.0 | ||
# for node@>=14.20.0 | ||
npm install --save mail-time | ||
# for node@<14.20.0 | ||
npm install --save mail-time@=1.3.4 | ||
# for node@<8.9.0 | ||
@@ -154,15 +174,19 @@ npm install --save mail-time@=0.1.7 | ||
```js | ||
// import as ES Module | ||
import MailTime from 'mail-time'; | ||
// requires as CommonJS | ||
const MailTime = require('mail-time'); | ||
``` | ||
Create nodemailer's transports (see [`nodemailer` docs](https://github.com/nodemailer/nodemailer/tree/v2#setting-up)): | ||
Create nodemailer's transports, for details see [`nodemailer` docs](https://github.com/nodemailer/nodemailer/tree/v2#setting-up): | ||
```js | ||
const transports = []; | ||
const nodemailer = require('nodemailer'); | ||
import nodemailer from 'nodemailer'; | ||
// Use DIRECT transport | ||
// and enable sending email from localhost | ||
// install "nodemailer-direct-transport" NPM package: | ||
const directTransport = require('nodemailer-direct-transport'); | ||
import directTransport from 'nodemailer-direct-transport'; | ||
const transports = []; | ||
const directTransportOpts = { | ||
@@ -436,2 +460,4 @@ pool: false, | ||
- Upload and share files using [☄️ meteor-files.com](https://meteor-files.com/?ref=github-mail-time-repo-footer) — Continue interrupted file uploads without losing any progress. There is nothing that will stop Meteor from delivering your file to the desired destination | ||
- Use [▲ ostr.io](https://ostr.io?ref=github-mail-time-repo-footer) for [Server Monitoring](https://snmp-monitoring.com), [Web Analytics](https://ostr.io/info/web-analytics?ref=github-mail-time-repo-footer), [WebSec](https://domain-protection.info), [Web-CRON](https://web-cron.info) and [SEO Pre-rendering](https://prerendering.com) of a website | ||
- Star on [GitHub](https://github.com/veliovgroup/mail-time) | ||
@@ -443,2 +469,1 @@ - Star on [NPM](https://www.npmjs.com/package/mail-time) | ||
- [Support via PayPal](https://paypal.me/veliovgroup) — support our open source contributions | ||
- Use [ostr.io](https://ostr.io) — [Monitoring](https://snmp-monitoring.com), [Analytics](https://ostr.io/info/web-analytics), [WebSec](https://domain-protection.info), [Web-CRON](https://web-cron.info) and [Pre-rendering](https://prerendering.com) for a website |
Sorry, the diff of this file is not supported yet
71686
1162
461
Yes
5
2
Updateddeepmerge@^4.3.1
Updatedjosk@^3.1.1