mail-time 3.0.0 โ†’ 4.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/README.md CHANGED
@@ -1,81 +1,52 @@
1
- [![support](https://img.shields.io/badge/support-GitHub-white)](https://github.com/sponsors/dr-dimitru)
2
- [![support](https://img.shields.io/badge/support-PayPal-white)](https://paypal.me/veliovgroup)
3
- <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>
4
- <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>
5
1
 
6
2
  # MailTime
7
3
 
8
- "Mail-Time" is NPM package for mail queue management. 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.
9
-
10
- Every `MailTime` instance can have `type` configured as *Server* or *Client*. *Server* type of `MailTime` is great for creating an emailing micro-service app.
11
-
12
- 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.
13
-
14
- ## ToC
15
-
16
- - [How it works?](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#how-it-works)
17
- - [With single SMTP](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#single-point-of-failure)
18
- - [With multiple SMTP](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#multiple-smtp-providers)
19
- - [For horizontally-scaled apps](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#sending-emails-from-cluster-of-servers)
20
- - [Features](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#features)
21
- - [Installation](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#installation)
22
- - [Meteor.js usage](https://github.com/veliovgroup/mail-time/blob/master/docs/meteor.md)
23
- - [Usage examples](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#basic-usage)
24
- - [Require/Import `mail-time`](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#1-require-package)
25
- - [Create NodeMailer's transports](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#2-create-nodemailers-transports)
26
- - [Initiate `mail-time`](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#3-initiate-mail-time)
27
- - [Connect to Redis](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#3a-initiate-and-connect-to-redis)
28
- - [Connect to MongoDB](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#3b-initiate-and-connect-to-mongodb)
29
- - [Initiate as *Client* instance](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#3c-optionally-create-client-type-of-mailtime)
30
- - [Initiate two `MailTime` instances within the single app](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#two-mailtime-instances-usage-example)
31
- - [Use templates](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#passing-variables-to-the-template)
32
- - Different storage configurations:
33
- - [Use MongoDB for queue and scheduler](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#using-mongodb-for-queue-and-scheduler)
34
- - [Use MongoDB for queue and Redis for scheduler](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#using-mongodb-for-queue-and-redis-for-scheduler)
35
- - [Use Redis for queue and MongoDB for scheduler](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#using-redis-for-queue-and-mongodb-for-scheduler)
36
- - [Use Redis for queue and scheduler](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#using-redis-for-queue-and-scheduler)
37
- - [API](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#api)
38
- - [`new MailTime` *Constructor*](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#new-mailtimeopts-constructor)
39
- - [`new RedisQueue` *Constructor*](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#new-redisqueueopts-constructor)
40
- - [`new MongoQueue` *Constructor*](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#new-mongoqueueopts-constructor)
41
- - [`.sendMail()`](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#sendmailopts)
42
- - [`.cancelMail()`](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#cancelmailuuid)
43
- - [Default Template](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#static-mailtimetemplate)
44
- - [Custom Templates](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#template-example)
45
- - [Running tests](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#testing)
46
-
47
- ## Main features:
48
-
49
- - ๐Ÿ‘จโ€๐Ÿ”ฌ ~94% tests coverage;
50
- - ๐Ÿ“ฆ Two simple dependencies, written from scratch for top performance;
51
- - ๐Ÿข Synchronize email queue across multiple (horizontally scaled) servers;
52
- - ๐Ÿ’ช Bulletproof design, built-in retries.
53
-
54
- ## How does it work?
55
-
56
- Redundant solution for email transmission.
4
+ [![npm version](https://img.shields.io/npm/v/mail-time.svg?label=mail-time)](https://www.npmjs.com/package/mail-time)
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/veliovgroup/mail-time/ci.yml?branch=master)](https://github.com/veliovgroup/mail-time/actions)
6
+ [![license](https://img.shields.io/npm/l/mail-time.svg)](LICENSE)
7
+ [![GitHub Sponsors](https://img.shields.io/github/sponsors/dr-dimitru?label=Sponsor)](https://github.com/sponsors/dr-dimitru)
8
+ [![Donate](https://img.shields.io/badge/Donate-PayPal-00457C?logo=paypal&logoColor=white)](https://paypal.me/veliovgroup)
57
9
 
58
- ### Single point of failure
10
+ Bulletproof email queue for [horizontally scaled](#sending-emails-from-a-cluster) Node.js & Bun apps. Built on top of [`nodemailer`](https://github.com/nodemailer/nodemailer) and [`josk`](https://github.com/veliovgroup/josk). Single runtime dependency, ESM + CJS, full TypeScript declarations.
11
+
12
+ `MailTime` runs in one of two modes:
13
+
14
+ - **`server`** โ€” drains the queue and sends emails via SMTP. The cluster-aware lease guarantees exactly one server sends each email even when many are running.
15
+ - **`client`** โ€” only enqueues emails. Use for app servers in a "dedicated mail micro-service" topology.
16
+
17
+ Many clients + one or more servers coexist behind the same `prefix` in the same store.
18
+
19
+ ## Features
59
20
 
60
- Issue - mitigate a single point of failure via persistent queue and re-send attempts
21
+ - ๐Ÿข **Horizontally scaled** โ€” synchronize one queue across N processes/hosts/DCs.
22
+ - ๐Ÿ” **Multi-SMTP rotation** โ€” `backup` (failover) and `balancer` (round-robin) strategies.
23
+ - ๐Ÿ’ช **Built-in retries** โ€” storage-backed retry with per-letter transport pinning.
24
+ - ๐ŸŽฏ **Per-recipient retries** โ€” when a multi-`to` send is partially rejected, only the un-accepted addresses are retried; delivered ones never see a duplicate.
25
+ - ๐Ÿ“ฎ **Email concatenation** โ€” fold same-`to` emails arriving inside a window into one letter.
26
+ - ๐ŸŽ›๏ธ **One-line setup** โ€” built-in [presets](#settings-presets) for `transactional`, `otp`, `newsletter`, `marketing`, `notifications`, `alerts`.
27
+ - ๐Ÿ›ข๏ธ **Three first-party storages** โ€” MongoDB, Redis, PostgreSQL. Plus a [custom-adapter contract](https://github.com/veliovgroup/mail-time/blob/master/docs/queue-api.md).
28
+ - ๐Ÿ“ฆ **Bun โ‰ฅ 1.1.0 & Node โ‰ฅ 20.9.0** โ€” same code, both runtimes.
29
+ - ๐Ÿค– **Ships with AI agent skills** โ€” see [AI agent skills](#ai-agent-skills) below.
30
+ - ๐Ÿ“ **Hand-tuned ESM + CJS + TypeScript declarations**.
31
+ - ๐Ÿงช **95%+ Jest coverage** (85% threshold enforced) + Mocha integration tests for every adapter.
32
+
33
+ ## How it works
34
+
35
+ ### Single point of failure
61
36
 
62
37
  ```ascii
63
38
  |----------------| |------| |------------------|
64
39
  | Other mailer | ------> | SMTP | ------> | ^_^ Happy user |
65
40
  |----------------| |------| |------------------|
66
41
 
67
- The scheme above will work as long as SMTP service is available
68
- or connection between your server and SMPT is up. Once network
69
- failure occurs or SMTP service is down - users won't be happy
42
+ The scheme above works only as long as SMTP is up.
70
43
 
71
44
  |----------------| \ / |------| |------------------|
72
45
  | Other mailer | --X---> | SMTP | ------> | 0_o Disappointed |
73
46
  |----------------| / \ |------| |------------------|
74
47
  ^- email lost in vain
75
48
 
76
- Single SMTP solution may work in case of network or other failures
77
- As long as MailTime has not received confirmation what email is sent
78
- it will keep the letter in the queue and retry to send it again
49
+ MailTime keeps every letter in the queue until SMTP confirms delivery.
79
50
 
80
51
  |----------------| / |------| |------------------|
81
52
  | Mail Time | --X---> | SMTP | ------> | ^_^ Happy user |
@@ -87,7 +58,7 @@ it will keep the letter in the queue and retry to send it again
87
58
 
88
59
  ### Multiple SMTP providers
89
60
 
90
- Rotate email transports by using multiple SMTP providers. *MailTime* support two strategies `backup` (*rotate when failed*) and `balancer` (*round-robin rotation*)
61
+ `backup` falls over on failure; `balancer` round-robins:
91
62
 
92
63
  ```ascii
93
64
  |--------|
@@ -103,18 +74,14 @@ Rotate email transports by using multiple SMTP providers. *MailTime* support two
103
74
  |--------|
104
75
  ```
105
76
 
106
- ### Sending emails from cluster of servers
77
+ ### Sending emails from a cluster
107
78
 
108
- It is common to have horizontally scaled "Cluster" of servers for load-balancing and for durability.
109
-
110
- 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.
111
-
112
- For the maximum durability and agility each Application Server can run *MailTime* in the "Server" mode:
79
+ Most apps schedule recurring emails (daily digest, weekly summary). On a single server this is trivial. In a cluster, every node would otherwise send the same email N times. MailTime's lease prevents the duplicate sends.
113
80
 
114
81
  ```ascii
115
82
  |===================THE=CLUSTER===================| |=QUEUE=|
116
83
  | |----------| |----------| |----------| | | | |--------|
117
- | | App | | App | | App | | | |-->| SMTP 1 |------\
84
+ | | MailTime | | MailTime | | MailTime | | | |-->| SMTP 1 |------\
118
85
  | | Server 1 | | Server 2 | | Server 3 | | | | |--------| \
119
86
  | |-----\----| |----\-----| |----\-----| | | | |-------------|
120
87
  | \---------------\----------------\----------> | |--------| | ^_^ |
@@ -127,17 +94,17 @@ For the maximum durability and agility each Application Server can run *MailTime
127
94
  |=================================================| |=======|
128
95
  ```
129
96
 
130
- 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:
97
+ For a dedicated mail machine (rDNS / PTR records), use `type: 'client'` on app servers and a single `type: 'server'` micro-service:
131
98
 
132
99
  ```ascii
133
100
  |===================THE=CLUSTER===================| |=QUEUE=| |===Mail=Time===|
134
101
  | |----------| |----------| |----------| | | | | | |--------|
135
- | | App | | App | | App | | | | | Micro-service |-->| SMTP 1 |------\
136
- | | Server 1 | | Server 2 | | Server 3 | | | | | running | |--------| \
102
+ | | MailTime | | MailTime | | MailTime | | | | | Micro-service |-->| SMTP 1 |------\
103
+ | | Client 1 | | Client 2 | | Client 3 | | | | | running | |--------| \
137
104
  | |-----\----| |----\-----| |----\-----| | | | | MailTime as | |-------------|
138
105
  | \---------------\----------------\----------> | | "Server" only | |--------| | ^_^ |
139
106
  | | | | | sending |-->| SMTP 2 |-->| Happy users |
140
- | Each "App Server" runs MailTime as | | | | emails | |--------| |-------------|
107
+ | Each "App" runs MailTime as | | | | emails | |--------| |-------------|
141
108
  | a "Client" only placing emails to the queue. | | <-------- | /
142
109
  | | | --------> | |--------| /
143
110
  | | | | | |-->| SMTP 3 |-----/
@@ -145,666 +112,415 @@ To split roles *MailTime* can run on a dedicated machine as micro-service. This
145
112
  |=================================================| |=======| |===============|
146
113
  ```
147
114
 
148
- ## Features
149
-
150
- - __Email Queue__ - Managed via MongoDB, Redis, or [Custom Queue](https://github.com/veliovgroup/mail-time/blob/master/docs/queue-api.md). Storage-based queue will survive server reboots and failures
151
- - __Made for horizontally scaled multi-server setups__ - *MailTime* is made to run in multi-server environments, like "Clusters", multiple app instances, load balanced solutions, and replications. *MailTime* is the perfect fit for applications scaled on a single machine, multiple virtual servers, multiple "bare metal" servers, within single or multiple data centers
152
- - __Email concatenation__ - Reduce amount of sent emails to a single user with concatenation, and avoid mistakenly duplicated emails. When "email concatenation" is enabled the same emails (*checked by addressee and content*) won't be sent twice. If emails are sent multiple times, due to issues in logic or application failures, - enable "email concatenation" to solve this behavior
153
- - __Multiple NodeMailer/SMTP transports__ โ€” Support for multiple SMPT transports implemented in two modes - `backup` and `balancing`. Use this feature to reduce the cost of SMTP services and add extra layer of durability. When one of the transports is failing to send an email โ€” `mail-time` will switch to the next one
154
- - __Sending retries__ โ€” Built-in retries for failed to send emails due to network or other failures
155
- - __Templating__ โ€” Built with support of [Mustache](https://mustache.github.io/)-like placeholders, see [templating docs](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#passing-variables-to-the-template)
115
+ See [docs/multi-instance.md](https://github.com/veliovgroup/mail-time/blob/master/docs/multi-instance.md) and [docs/dedicated-mail-host.md](https://github.com/veliovgroup/mail-time/blob/master/docs/dedicated-mail-host.md) for full topologies.
156
116
 
157
117
  ## Installation
158
118
 
159
- To implement Server functionality โ€” begin with installing `nodemailer`, although this package meant to be used with `nodemailer`, it's not added as the dependency, as `nodemailer` not needed by Client, and to give freedom to choose `nodemailer`'s version to fit every project needs:
119
+ ```sh
120
+ npm install --save mail-time nodemailer
121
+ # pick at least one storage driver:
122
+ npm install --save redis # for RedisQueue
123
+ npm install --save mongodb # for MongoQueue
124
+ npm install --save pg # for PostgresQueue
160
125
 
161
- ```shell
162
- npm install --save nodemailer
126
+ # Bun:
127
+ bun add mail-time nodemailer
163
128
  ```
164
129
 
165
- Install *MailTime* package:
130
+ > [!NOTE]
131
+ > `nodemailer` and adapter drivers are peers (not bundled) so you can pin your own versions.
166
132
 
167
- ```shell
168
- # for node@>=14.20.0
169
- npm install --save mail-time
133
+ > [!IMPORTANT]
134
+ > Upgrading from v3? See [Migration from 3.x](#migration-from-3x) and the full [v4.0.0 release notes](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v3-v4.md).
170
135
 
171
- # for node@<14.20.0
172
- npm install --save mail-time@=1.3.4
136
+ For Meteor.js usage see [docs/meteor.md](https://github.com/veliovgroup/mail-time/blob/master/docs/meteor.md).
173
137
 
174
- # for node@<8.9.0
175
- npm install --save mail-time@=0.1.7
176
- ```
138
+ ## AI agent skills
139
+
140
+ MailTime ships a Claude / Copilot / Cursor / Codex / Gemini-ready skill bundle. Install it once in your project (or globally) and your AI agent will reach for the right preset, adapter, and pitfall list without you having to paste docs into the chat.
177
141
 
178
- ## Basic usage
142
+ ```sh
143
+ # Install the MailTime skill into the current project:
144
+ npx skills add veliovgroup/mail-time
179
145
 
180
- Setup Nodemailer's transports, Queue storage, and *MailTime* instance
146
+ # Recommended: also install the JoSk skill โ€” MailTime is built on JoSk,
147
+ # and deep scheduler questions resolve through JoSk's contract.
148
+ npx skills add veliovgroup/josk
149
+ ```
181
150
 
182
- ### Steps to get started
151
+ The `npx skills` CLI ([vercel-labs/skills](https://github.com/vercel-labs/skills)) supports 50+ AI coding agents. Pass `-g` to install user-wide, or `-a claude-code` to target a specific agent. The bundled MailTime skill covers the public API, every queue adapter, the preset table, tuning levers, and common pitfalls โ€” it's the same material as the README and `docs/`, structured for an LLM.
183
152
 
184
- See steps 1-4 below to learn about different parts of *MailTime* library and how it can get used. From configuration options to sending email
153
+ ## Quick start
185
154
 
186
- 1. [Require `mail-time` package](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#1-require-package)
187
- 2. [Create NodeMailer's transports](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#2-create-nodemailers-transports)
188
- 3. [Initiate `mail-time` *server*](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#3-initiate-mail-time)
189
- - a. [Connect to Redis](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#3a-initiate-and-connect-to-redis); Or
190
- - b. [Connect to MongoDB](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#3b-initiate-and-connect-to-mongodb); And
191
- - c. [*optionally*] [initiate `mail-time` as *client*](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#3c-optionally-create-client-type-of-mailtime)
192
- 4. [Start sending emails](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#4-send-email)
155
+ Three things every MailTime needs: a connected storage **client**, one or more **nodemailer transports** (server only), and a **`josk.adapter`** that points at the scheduler storage (server only). Past that, reach for a [preset](#settings-presets) instead of hand-tuning every knob.
193
156
 
194
- #### 1. Require package
157
+ ### 1. Import
195
158
 
196
159
  ```js
197
- // import as ES Module
198
- import { MailTime, MongoQueue, RedisQueue } from 'mail-time';
160
+ // ESM
161
+ import { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } from 'mail-time';
199
162
 
200
- // require as CommonJS
201
- const { MailTime, MongoQueue, RedisQueue } = require('mail-time');
163
+ // CommonJS
164
+ const { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } = require('mail-time');
202
165
  ```
203
166
 
204
- #### 2. Create NodeMailer's transports
205
-
206
- For compatibility and flexibility *MailTime* has no dependency on `nodemailer` it should be installed and imported manually. Create one or more "SMTP transports" before initializing new *MailTime* instance.
167
+ ### 2. Create nodemailer transports
207
168
 
208
- For details and full list of options available in `.createTransport()` see [`nodemailer` docs](https://nodemailer.com/smtp/)
169
+ Each transport must expose `.options` (set automatically by `nodemailer.createTransport({...})`). MailTime merges any `options.mailOptions` defaults onto every letter. To produce a `From:` header *from* a transport's `options.from`, set the constructor's `from: (t) => t.options.from` callback (the next example does this). Without that callback, `From:` falls back to per-letter `sendMail({ from })` or `options.mailOptions.from` on the transport itself.
209
170
 
210
171
  ```js
211
172
  // transports.js
212
173
  import nodemailer from 'nodemailer';
213
- // Use DIRECT transport
214
- // and enable sending email from localhost
215
- // install "nodemailer-direct-transport" NPM package:
216
- import directTransport from 'nodemailer-direct-transport';
217
-
218
- const transports = [];
219
- const directTransportOpts = {
220
- pool: false,
221
- direct: true,
222
- name: 'mail.example.com',
223
- from: 'no-reply@example.com',
224
- };
225
- transports.push(nodemailer.createTransport(directTransport(directTransportOpts)));
226
- // IMPORTANT: Add `.options` to a newly created transport,
227
- // this is necessary to make sure options are available to MailTime package:
228
- transports[0].options = directTransportOpts;
229
-
230
- // Private SMTP
231
- transports.push(nodemailer.createTransport({
232
- host: 'smtp.example.com',
233
- from: 'no-reply@example.com',
234
- auth: {
235
- user: 'no-reply',
236
- pass: 'xxx'
237
- },
238
- }));
239
-
240
- // Google Apps SMTP
241
- transports.push(nodemailer.createTransport({
242
- host: 'smtp.gmail.com',
243
- from: 'no-reply@mail.example.com',
244
- auth: {
245
- user: 'no-reply@mail.example.com',
246
- pass: 'xxx'
247
- },
248
- }));
249
174
 
250
- // Mailing service (SparkPost as example)
251
- transports.push(nodemailer.createTransport({
252
- host: 'smtp.sparkpostmail.com',
253
- port: 587,
254
- from: 'no-reply@mail2.example.com',
255
- auth: {
256
- user: 'SMTP_Injection',
257
- pass: 'xxx'
258
- },
259
- }));
260
-
261
- export { transports };
175
+ export const transports = [
176
+ nodemailer.createTransport({
177
+ host: 'smtp.example.com',
178
+ from: 'no-reply@example.com',
179
+ auth: { user: 'no-reply', pass: process.env.SMTP_PASS },
180
+ }),
181
+ ];
262
182
  ```
263
183
 
264
- #### 3. Initiate `mail-time`
265
-
266
- Create new instance of *MailTime* in the *Server* mode, โ€” it will be able to __send__ and __add__ emails to the queue.
267
-
268
- #### 3a. Initiate and connect to Redis
184
+ ### 3. Initialize MailTime with a preset
269
185
 
270
- Connecting to Redis before initiating `new MailTime` instance:
186
+ Pick the preset that matches the email class. Supply your own `queue`, `transports`, `josk.adapter`, and `prefix`. **Setting `prefix` on the constructor propagates into the queue adapter and the JoSk adapter automatically** โ€” no need to repeat it.
271
187
 
272
188
  ```js
273
- // mail-queue.js
274
- import { MailTime, RedisQueue } from 'mail-time';
189
+ // mail-queue.js โ€” transactional emails on Redis
190
+ import { MailTime, RedisQueue, mailTimePreset } from 'mail-time';
275
191
  import { createClient } from 'redis';
276
192
  import { transports } from './transports.js';
277
193
 
278
- // Use REDIS_URL environment variable to store connection string to MongoDB
279
- // example: "REDIS_URL=redis://127.0.0.1:6379/myapp node mail-micro-service.js"
280
194
  const redisClient = await createClient({ url: process.env.REDIS_URL }).connect();
281
- const mailQueue = new MailTime({
195
+
196
+ const mailQueue = new MailTime(mailTimePreset('transactional', {
197
+ type: 'server',
198
+ prefix: 'app',
199
+ queue: new RedisQueue({ client: redisClient }),
200
+ josk: { adapter: { type: 'redis', client: redisClient } },
282
201
  transports,
283
- queue: new MongoQueue({
284
- client: redisClient,
285
- }),
286
- josk: {
287
- adapter: {
288
- type: 'redis',
289
- client: redisClient,
290
- }
202
+ from: (t) => `"Awesome App" <${t.options.from}>`,
203
+ onSent(email, info) {
204
+ console.log('sent', email.uuid, info);
291
205
  },
292
- template: MailTime.Template // Use default template
293
- from(transport) {
294
- // To pass spam-filters `from` field should be correctly set
295
- // for each transport, check `transport` object for more options
296
- return `"Awesome App" <${transport.options.from}>`;
206
+ onError(error, email, info) {
207
+ console.error('failed', email.uuid, error, info);
297
208
  },
298
- onError(error, email, details) {
299
- console.log(`Email "${email.mailOptions.subject}" wasn't sent to ${email.mailOptions.to}`, error, details);
300
- },
301
- onSent(email, details) {
302
- console.log(`Email "${email.mailOptions.subject}" successfully sent to ${email.mailOptions.to}`, details);
303
- },
304
- });
209
+ }));
305
210
 
211
+ await mailQueue.ready();
306
212
  export { mailQueue };
307
213
  ```
308
214
 
309
- #### 3b. Initiate and connect to MongoDB
215
+ Switching stores is one import change. The same pattern works with `MongoQueue({ db })` + `{ type: 'mongo', db }`, or `PostgresQueue({ client: pgPool })` + `{ type: 'postgres', client: pgPool }`.
310
216
 
311
- Connecting to MongoDB before initiating `new MailTime` instance:
217
+ ### 4. Send and cancel
312
218
 
313
219
  ```js
314
- // mail-queue.js
315
- import { MailTime, MongoQueue } from 'mail-time';
316
- import { MongoClient } from 'mongodb';
317
- import { transports } from './transports.js';
220
+ import { mailQueue } from './mail-queue.js';
318
221
 
319
- // Use MONGO_URL environment variable to store connection string to MongoDB
320
- // example: "MONGO_URL=mongodb://127.0.0.1:27017/myapp node mail-micro-service.js"
321
- const mongodb = (await MongoClient.connect(process.env.MONGO_URL)).db('database');
322
- const mailQueue = new MailTime({
323
- transports,
324
- queue: new MongoQueue({
325
- db: mongodb,
326
- }),
327
- josk: {
328
- adapter: {
329
- type: 'mongo',
330
- db: mongodb,
331
- }
332
- },
333
- template: MailTime.Template // Use default template
334
- from(transport) {
335
- // To pass spam-filters `from` field should be correctly set
336
- // for each transport, check `transport` object for more options
337
- return `"Awesome App" <${transport.options.from}>`;
338
- },
339
- onError(error, email, details) {
340
- console.log(`Email "${email.mailOptions.subject}" wasn't sent to ${email.mailOptions.to}`, error, details);
341
- },
342
- onSent(email, details) {
343
- console.log(`Email "${email.mailOptions.subject}" successfully sent to ${email.mailOptions.to}`, details);
344
- },
222
+ const uuid = await mailQueue.sendMail({
223
+ to: 'user@example.com',
224
+ subject: 'You\'ve got an email!',
225
+ text: 'Plain text body',
226
+ html: '<h1>HTML</h1><p>Styled body</p>',
345
227
  });
346
228
 
347
- export { mailQueue };
229
+ // later โ€” cancel before sendAt:
230
+ await mailQueue.cancelMail(uuid); // true | false
348
231
  ```
349
232
 
350
- #### 3c. Optionally create *Client* type of *MailTime*
233
+ `sendMail` returns a stable `uuid` you can store for cancellation. Pass any [nodemailer message option](https://nodemailer.com/message/) โ€” `to`, `subject`, `text`, `html`, `attachments`, `cc`, `bcc`, custom headers, etc.
351
234
 
352
- Only __one__ `MailTime` *Server* instance required to send email. In the other parts of an app (like UI units or in sub-apps) use `mail-time` in the *Client* mode to __add__ emails to queue
235
+ ### 5. Client-only mode
236
+
237
+ App servers that only enqueue need no `transports`, no `josk` โ€” just the queue. Use the **same `prefix`** as the server that drains the class.
353
238
 
354
239
  ```js
355
- // mail-queue.js
356
240
  import { MailTime, RedisQueue } from 'mail-time';
357
241
  import { createClient } from 'redis';
358
242
 
359
- const mailQueue = new MailTime({
243
+ const redisClient = await createClient({ url: process.env.REDIS_URL }).connect();
244
+
245
+ export const mailQueue = new MailTime({
360
246
  type: 'client',
361
- queue: new RedisQueue({
362
- client: await createClient({ url: 'redis://url' }).connect()
363
- }),
247
+ prefix: 'app',
248
+ queue: new RedisQueue({ client: redisClient }),
364
249
  });
365
-
366
- export { mailQueue };
367
250
  ```
368
251
 
369
- #### 4. Send email
370
-
371
- Import created `mailQueue` where needed and call `.sendMail()` method. See [NodeMailer's message configuration documentation](https://nodemailer.com/message/) for details
252
+ ### 6. Shutdown
372
253
 
373
254
  ```js
374
- import { mailQueue } from './mail-queue.js';
375
-
376
- await mailQueue.sendMail({
377
- to: 'user@gmail.com',
378
- subject: 'You\'ve got an email!',
379
- text: 'Plain text message',
380
- html: '<h1>HTML</h1><p>Styled message</p>'
255
+ process.on('SIGTERM', async () => {
256
+ await mailQueue.destroy({ drain: true }); // stop scheduler, then wait for in-flight SMTPs
381
257
  });
382
258
  ```
383
259
 
384
- ### Using MongoDB for queue and scheduler
260
+ Or explicitly: `mailQueue.destroy(); await mailQueue.drain();`
385
261
 
386
- *MailTime* uses separate storage for Queue management and Scheduler. In the example below MongoDB is used for both
262
+ `destroy()` is idempotent and stops the scheduler. Always call it from tests; pair with `drain()` when iterate-driven sends ran.
387
263
 
388
- ```js
389
- import { MailTime, MongoQueue } from 'mail-time';
390
- import { MongoClient } from 'mongodb';
391
- import { transports } from './transports.js';
264
+ ## Storage layouts
392
265
 
393
- const db = (await MongoClient.connect('mongodb://url')).db('database');
394
- const mailQueue = new MailTime({
395
- queue: new MongoQueue({
396
- db: db,
397
- }),
398
- josk: {
399
- adapter: {
400
- type: 'mongo',
401
- db: db,
402
- }
403
- },
404
- transports,
405
- from(transport) {
406
- // To pass spam-filters `from` field should be correctly set
407
- // for each transport, check `transport` object for more options
408
- return `"Awesome App" <${transport.options.from}>`;
409
- }
410
- });
411
- ```
266
+ Queue storage and scheduler storage can be the same store or different ones. Use this matrix:
412
267
 
413
- ### Using MongoDB for queue and Redis for scheduler
268
+ | Queue | Scheduler (`josk`) | Best for |
269
+ | -------- | ------------------ | -------------------------------------------- |
270
+ | Postgres | Postgres | Multi-DC, mixed clocks, strict exactly-once. |
271
+ | Redis | Redis | High-throughput single-region. |
272
+ | Mongo | Mongo | Apps already on Mongo (especially Meteor). |
273
+ | Mongo | Redis | Durable letter storage + sub-second polling. |
274
+ | Redis | Mongo | Hot Redis letters + Mongo for scheduler. |
414
275
 
415
- *MailTime* uses separate storage for Queue management and Scheduler. In the example below MongoDB is used for queue and Redis is used for scheduler
276
+ For split-store setups pass a different client to each:
416
277
 
417
278
  ```js
418
- import { MailTime, MongoQueue } from 'mail-time';
419
- import { MongoClient } from 'mongodb';
420
- import { createClient } from 'redis';
421
- import { transports } from './transports.js';
422
-
423
279
  const mailQueue = new MailTime({
424
- queue: new MongoQueue({
425
- db: (await MongoClient.connect('mongodb://url')).db('database'),
426
- }),
280
+ prefix: 'app',
281
+ queue: new MongoQueue({ db }),
282
+ transports,
427
283
  josk: {
428
- adapter: {
429
- type: 'redis',
430
- client: await createClient({ url: 'redis://url' }).connect(),
431
- }
284
+ adapter: { type: 'redis', client: redisClient },
432
285
  },
433
- transports,
434
- from(transport) {
435
- return `"Awesome App" <${transport.options.from}>`;
436
- }
286
+ /* ... */
437
287
  });
438
288
  ```
439
289
 
440
- ### Using Redis for queue and MongoDB for scheduler
290
+ ## Settings presets
441
291
 
442
- *MailTime* uses separate storage for Queue management and Scheduler. In the example below Redis is used for queue and MongoDB is used for scheduler
292
+ Each email class wants a different policy โ€” OTP must reach the inbox in seconds, a newsletter wants emails folded together, marketing tolerates retries spread over hours. `mailTimePreset(name, overrides)` applies a vetted shape in one line; you layer your own `queue` / `transports` / `josk.adapter` / `prefix` on top.
443
293
 
444
294
  ```js
445
- import { MailTime, RedisQueue } from 'mail-time';
446
- import { MongoClient } from 'mongodb';
447
- import { createClient } from 'redis';
448
- import { transports } from './transports.js';
295
+ import { MailTime, RedisQueue, mailTimePreset } from 'mail-time';
449
296
 
450
- const mailQueue = new MailTime({
451
- queue: new RedisQueue({
452
- client: await createClient({ url: 'redis://url' }).connect(),
453
- }),
454
- josk: {
455
- adapter: {
456
- type: 'mongo',
457
- db: (await MongoClient.connect('mongodb://url')).db('database'),
458
- }
459
- },
460
- transports,
461
- from(transport) {
462
- return `"Awesome App" <${transport.options.from}>`;
463
- }
464
- });
297
+ const mailTime = new MailTime(mailTimePreset('otp', {
298
+ prefix: 'otp',
299
+ queue: new RedisQueue({ client: redisClient }),
300
+ transports: [otpTransport],
301
+ josk: { adapter: { type: 'redis', client: redisClient } },
302
+ }));
465
303
  ```
466
304
 
467
- ### Using Redis for queue and scheduler
305
+ | Preset | Shape | Best for |
306
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
307
+ | `transactional` | `retries: 30`, `retryDelay: 10s`, `concatEmails: false`, `concurrency: 1`, `josk.zombieTime: 120s` | Receipts, password resets, account changes, welcome emails. |
308
+ | `otp` | `retries: 5`, `retryDelay: 2s`, snappy `revolvingInterval: 1024` + jitter `256/1024`, `concurrency: 4`, `sendingTimeout: 60s` | Sign-in codes, 2FA, verification codes โ€” stale OTPs aren't worth resending forever. |
309
+ | `newsletter` | `concatEmails: true` with a 5-minute fold window, `concatSubject: 'Your updates'`, `retries: 5`, `retryDelay: 60s`, `concurrency: 2`, `sendingTimeout: 10min`, `josk.zombieTime: 5min` | Scheduled digests, weekly summaries, "what's new" emails. |
310
+ | `marketing` | `retries: 10`, `retryDelay: 30s`, `concatEmails: false`, `concurrency: 5`, `josk.zombieTime: 3min` | Promotional / campaign blasts where each letter is unique. |
311
+ | `notifications` | `concatEmails: true` with a 60-second fold window, `concatSubject: 'New activity'`, `retries: 8`, `retryDelay: 30s`, `concurrency: 3`, `josk.zombieTime: 3min` | App / social activity (likes, mentions, follows) where bursts collapse into one letter. |
312
+ | `alerts` | `retries: 20`, `retryDelay: 5s`, snappy `revolvingInterval: 1024` + jitter `256/1024`, `concurrency: 2`, `sendingTimeout: 60s` | Ops / admin alerts: monitoring, error reports, escalations. |
468
313
 
469
- *MailTime* uses separate storage for Queue management and Scheduler. In the example below Redis is used for both
314
+ Presets are equally useful on `type: 'client'` instances โ€” keys that don't apply to the client role are simply ignored.
470
315
 
471
- ```js
472
- import { MailTime, RedisQueue } from 'mail-time';
473
- import { MongoClient } from 'mongodb';
474
- import { createClient } from 'redis';
475
- import { transports } from './transports.js';
316
+ ## Multiple instances (recommended)
476
317
 
477
- const redisClient = await createClient({ url: 'redis://url' }).connect();
478
- const mailQueue = new MailTime({
479
- queue: new RedisQueue({
480
- client: redisClient,
481
- }),
482
- josk: {
483
- adapter: {
484
- type: 'redis',
485
- client: redisClient,
486
- }
487
- },
488
- transports,
489
- from(transport) {
490
- return `"Awesome App" <${transport.options.from}>`;
491
- }
492
- });
493
- ```
318
+ **Run one `MailTime` per email class** when policies differ (OTP vs marketing vs receipts). Each class gets its own `MailTime` options and, when policies differ, its own `prefix`. Combine with [presets](#settings-presets) to keep the boilerplate to a single line per class.
494
319
 
495
- ### Two `MailTime` instances usage example
320
+ - **Same `prefix`** for every `client` and `server` that share one logical queue.
321
+ - **Different `prefix`** per class so namespaces don't collide.
322
+ - **Never** reuse `prefix` across two instances with different `concatEmails`, `retryDelay`, or other mail policy.
496
323
 
497
- Create two `MailTime` instances with different settings. One for urgent (*e.g. "transactional" emails*), and another one for other types of emails (*e.g. "marketing" emails*)
324
+ Full example wiring three classes (OTP / transactional / marketing) on one Redis connection, plus app-pod `client` setup, lives in [docs/multi-instance.md](https://github.com/veliovgroup/mail-time/blob/master/docs/multi-instance.md).
498
325
 
499
- ```js
500
- import { MailTime, RedisQueue } from 'mail-time';
501
- import { createClient } from 'redis';
502
- import { transports } from './transports.js';
503
- const redisClient = await createClient({ url: 'redis://url' }).connect();
326
+ ## Dedicated mail host
504
327
 
505
- // CREATE mailQueue FOR NON-URGENT EMAILS WHICH IS OKAY TO CONCATENATE
506
- const mailQueue = new MailTime({
507
- queue: new RedisQueue({
508
- client: redisClient,
509
- }),
510
- transports,
511
- strategy: 'backup',
512
- failsToNext: 1,
513
- concatEmails: true,
514
- josk: {
515
- adapter: {
516
- type: 'redis',
517
- client: redisClient
518
- },
519
- zombieTime: 120000
520
- }
521
- });
328
+ On a **single mail VM** (good rDNS / PTR, fixed SMTP credentials), run **2โ€“8 `server` processes** (~one per CPU core) โ€” typically **one process per email class**. Same `prefix` cluster-wide = one JoSk lease tick at a time, so extra pods on the same `prefix` buy **failover/HA**, not throughput.
522
329
 
523
- // CREATE mailInstantQueue FOR TRANSACTIONAL EMAILS AND ALERTS
524
- const mailInstantQueue = new MailTime({
525
- queue: new RedisQueue({
526
- client: redisClient,
527
- prefix: 'instant'
528
- }),
529
- transports,
530
- prefix: 'instant',
531
- retryDelay: 2000,
532
- strategy: 'backup',
533
- failsToNext: 1,
534
- concatEmails: false,
535
- josk: {
536
- adapter: {
537
- type: 'redis',
538
- client: redisClient
539
- },
540
- zombieTime: 20000
541
- }
542
- });
330
+ Full systemd unit + worker layout: [docs/dedicated-mail-host.md](https://github.com/veliovgroup/mail-time/blob/master/docs/dedicated-mail-host.md).
543
331
 
544
- await mailQueue.sendMail({
545
- to: 'user@gmail.com',
546
- subject: 'You\'ve got an email!',
547
- text: 'Plain text message',
548
- html: '<h1>HTML</h1><p>Styled message</p>'
549
- });
332
+ ## Tuning
550
333
 
551
- await mailInstantQueue.sendMail({
552
- to: 'user@gmail.com',
553
- subject: 'Sign in request',
554
- text: 'Your OTP login code: xxxx:',
555
- html: '<h1>Code:</h1><code>XXXX</code>'
556
- });
557
- ```
334
+ Defaults fit moderate traffic in a single region. Reach for a [preset](#settings-presets) first; tune individual knobs only when the preset doesn't cover your case. Full guide: [docs/tuning.md](https://github.com/veliovgroup/mail-time/blob/master/docs/tuning.md).
558
335
 
559
- ### Passing variables to the template
336
+ ### Option reference (when to touch)
560
337
 
561
- All options passed to the `.sendMail()` method are available inside `text`, `html`, and global templates
338
+ | Option | Default | Change when |
339
+ | ---------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
340
+ | `mode` | `'batch'` | `'one'` to claim a single row per tick (fairness over throughput across cluster nodes) |
341
+ | `concurrency` | `1` | Raise to send N emails in parallel per instance. Bounded by your SMTP / API rate limits. The CAS on `isSending` keeps it safe. |
342
+ | `sendingTimeout` | `300000` (5 min) | Stale-lock recovery window. Must exceed worst-case SMTP roundtrip; lower it only when you're confident sends never take that long. |
343
+ | `revolvingInterval` | `1536` ms | Lower โ†’ faster pickup; higher โ†’ less scheduler I/O |
344
+ | `josk.minRevolvingDelay` / `maxRevolvingDelay` | `512` / `2048` | Lower both โ†’ snappier polls, more storage load |
345
+ | `josk.zombieTime` | `60000` | **Never below 60s.** Iterate releases the JoSk lease as soon as scanning ends โ€” only a stalled storage scan can blow this. |
346
+ | `josk.concurrency` | `Infinity` | Set `1` if scheduler ticks overlap while `iterate` still runs |
347
+ | `josk.execute` | `'batch'` | Usually leave default; MailTime only registers one JoSk task per instance |
348
+ | `josk.lockOwnerId` | random | Set in production for observability |
349
+ | `retries` / `retryDelay` | `59` / `60s` | `retries` is *after* the first attempt; default `59` means 60 total attempts. Per email class โ€” transactional shorter, marketing longer. |
350
+ | `concatEmails` / `concatDelay` | `false` / `60s` | On for notification batching; off for OTP and receipts |
351
+ | `prefix` | `''` | **Same** on all `client` + `server` for one queue; **different** only per email class / shard |
562
352
 
563
- ```js
564
- const templates = {
565
- global: '<html xmlns="http://www.w3.org/1999/xhtml"><head><title>{{subject}}</title></head><body>{{{html}}}<footer>Message sent to @{{username}} user ({{to}})</footer></body></html>',
566
- signInCode: {
567
- text: 'Hello @{{username}}! Here\'s your login code: {{code}}',
568
- html: `<h1>Sign-in request</h1><p>Hello @{{username}}! <p>Copy your login code below:</p> <pre><code>{{code}}</code></pre>`
569
- }
570
- };
353
+ For deeper JoSk semantics (lease lifecycle, scheduler adapters, recurring tasks), install the JoSk skill: **`npx skills add veliovgroup/josk`**.
571
354
 
572
- const mailQueue = new MailTime({
573
- queue: new RedisQueue({ /* ... */ }),
574
- template: templates.global
575
- });
355
+ ## Templates
576
356
 
577
- await mailQueue.sendMail({
578
- to: 'user@gmail.com',
579
- subject: 'Sign-in request',
580
- username: 'johndoe',
581
- code: 'XXXXX-YY',
582
- text: templates.signInCode.text,
583
- html: templates.signInCode.html
584
- });
585
- ```
357
+ Two Mustache-like placeholder forms:
586
358
 
587
- ## API
359
+ - `{{key}}` โ€” string interpolation, strips HTML from the value (safe for plain text).
360
+ - `{{{key}}}` โ€” raw HTML interpolation.
588
361
 
589
- All available constructor options and `.sendMail()` method API overview
590
-
591
- ### `new MailTime(opts)` constructor
592
-
593
- - `opts` {*object*} - Configuration object
594
- - `opts.type` {*string*} - [Optional] `client` or `server`, default - `server`
595
- - `opts.queue` {*RedisQueue*|*MongoQueue*|*CustomQueue*} - Queue storage driver instance
596
- - `opts.transports` {*[object]*} - [*Required for "server"*] An array of `nodemailer`'s transports, returned from `nodemailer.createTransport({})`. Required for `{type: 'server'}`
597
- - `opts.josk` {*object*} - [*Required for "server"*] [`JoSk` package](https://github.com/veliovgroup/josk#api) options
598
- - `opts.josk.adapter` {*object*|*RedisAdapter*|*MongoAdapter*|*CustomAdapter*} - Config object or *Adapter* instance
599
- - `opts.josk.adapter.type` {*string*} - One of `mongo` *or* `redis`; Pass `josk.adapter.type` to avoid burden of creating *Adapter* instance manually
600
- - `opts.josk.adapter.client` {*RedisClient*} - *RedisClient* instance
601
- - `opts.josk.adapter.db` {*Db*} - Mongo's *Db* instance
602
- - `opts.josk[option]` {*mix*} - Any other options passed to [`JoSk` instance](https://github.com/veliovgroup/josk#api)
603
- - `opts.from` {*function*} - [Optional] A function which returns *string* of `from` field, format: `"MyApp" <user@example.com>`
604
- - `opts.strategy` {*string*} - [Optional] `backup` or `balancer`, default - `backup`. If set to `backup`, first transport will be used unless failed to send `failsToNext` times. If set to `balancer` - transports will be used equally in round robin chain
605
- - `opts.failsToNext` {*number*} - [Optional] After how many failed "send attempts" switch to the next transport, applied only for `backup` strategy, default - `4`
606
- - `opts.prefix` {*string*} - [Optional] Use unique prefixes to create multiple `MailTime` instances within the same application
607
- - `opts.retries` {*number*} - [Optional] How many times resend failed emails, default - `60`
608
- - `opts.retryDelay` {*number*} - [Optional] Interval in *milliseconds* between send re-tries, default - `60000`
609
- - `opts.keepHistory` {*boolean*} - [Optional] By default sent emails not stored in the database. Set `{ keepHistory: true }` to keep queue task as it is in the database, default - `false`
610
- - `opts.concatEmails` {*boolean*} - [Optional] Concatenate email by `to` field (*e.g. to the same addressee*), default - `false`
611
- - `opts.concatSubject` {*string*} - [Optional] Email subject used in concatenated email, default - `Multiple notifications`
612
- - `opts.concatDelimiter` {*string*} - [Optional] HTML or plain string delimiter used between concatenated email, default - `<hr>`
613
- - `opts.concatDelay` {*number*} - [Optional] Time in *milliseconds* while emails are waiting to be concatenated, default - `60000`
614
- - `opts.revolvingInterval` {*number*} - [Optional] Interval in *milliseconds* in between queue checks, default - `256`
615
- - `opts.template` {*string*} - [Optional] Mustache-like template, default - `{{{html}}}`, all options passed to `sendMail` is available in Template, like `to`, `subject`, `text`, `html` or any other custom option. Use `{{opt}}` for string placeholders and `{{{opt}}}` for html placeholders
616
- - `opts.onError(error, email, details)` {*function*} - [Optional] called when email has failed to get sent and exhausted all send attempts (`opts.retries`), called with 3 arguments:
617
- - `error` {*Error*|*object*} - Error object
618
- - `email` {*object*} - email's object
619
- - `details` {*object*} - *not always present*, details from SMTP protocol
620
- - `opts.onSent(email, details)` {*function*} - [Optional] called when email was successfully handed over to receiving/recipient's SMTP server, called with 2 arguments:
621
- - `email` {*object*} - email's object
622
- - `details` {*object*} - *not always present*, details from SMTP server/protocol
362
+ Every `sendMail` option is available inside `text`, `html`, and the wrapping template:
623
363
 
624
364
  ```js
625
- import { MailTime, MongoQueue, RedisQueue } from 'mail-time';
626
- import nodemailer from 'nodemailer';
627
- import { createClient } from 'redis';
628
-
629
- const redisClient = await createClient({ url: 'redis://url' }).connect();
365
+ const layouts = {
366
+ envelope: `<html><body>{{{html}}}<footer>Sent to @{{username}} ({{to}})</footer></body></html>`,
367
+ otp: {
368
+ text: 'Hello @{{username}}! Your code: {{code}}',
369
+ html: '<h1>Sign-in</h1><p>Hello <b>@{{username}}</b></p><pre><code>{{code}}</code></pre>',
370
+ },
371
+ };
630
372
 
631
373
  const mailQueue = new MailTime({
632
- type: 'server',
633
- strategy: 'backup',
634
- prefix: 'appMailQueue',
635
- transports: [nodemailer.createTransport({/* ... */})],
636
- failsToNext: 4,
637
- retries: 60,
638
- retryDelay: 60000,
639
- keepHistory: false,
640
- concatEmails: false,
641
- concatDelay: 60000,
642
- concatDelimiter: '<hr>',
643
- concatSubject: 'Multiple notifications',
644
- revolvingInterval: 256,
645
- template: '{{{html}}}',
646
- queue: new RedisQueue({
647
- client: redisClient,
648
- prefix: 'appMailQueue',
649
- }),
650
- josk: {
651
- adapter: {
652
- type: 'redis',
653
- client: redisClient,
654
- }
655
- },
656
- from(transport) {
657
- // To pass spam-filters `from` field should be correctly set
658
- // for each transport, check `transport` object for more options
659
- return `"App Name" <${transport.options.from}>`;
660
- },
661
- onError(error, email, details) {
662
- console.log(`Email "${email.mailOptions.subject}" wasn't sent to ${email.mailOptions.to}`, error, details);
663
- },
664
- onSent(email, details) {
665
- console.log(`Email "${email.mailOptions.subject}" successfully sent to ${email.mailOptions.to}`, details);
666
- },
374
+ /* ... */
375
+ template: layouts.envelope,
667
376
  });
668
377
 
669
378
  await mailQueue.sendMail({
670
- to: 'johndoe@example.com',
671
- subject: 'Email subject',
672
- text: 'You have got email!',
673
- html: '<p>You have got email!</p>',
674
- });
675
- ```
676
-
677
- ### `new RedisQueue(opts)` constructor
678
-
679
- *Create Redis Queue instance.* Use for `opts.queue` when creating *MailTime* instance
680
-
681
- - `opts` {*object*} - Configuration object
682
- - `opts.client` {*RedisClient*} - Required, Redis'es `RedisClient` instance, like one returned from `await redis.createClient().connect()` method
683
- - `opts.prefix` {*string*} - Optional prefix for scope isolation; use when creating multiple `MailTime` instances within the single application
684
-
685
- ```js
686
- import { MailTime, RedisQueue } from 'mail-time';
687
- import { createClient } from 'redis';
688
-
689
- new RedisQueue({
690
- client: await createClient({ url: 'redis://url' }).connect(),
691
- prefix: 'appMailQueue',
379
+ to: 'user@example.com',
380
+ subject: 'Sign-in code',
381
+ username: 'mike',
382
+ code: 'A1B2-C3D4',
383
+ text: layouts.otp.text,
384
+ html: layouts.otp.html,
692
385
  });
693
386
  ```
694
387
 
695
- ### `new MongoQueue(opts)` constructor
696
-
697
- *Create MongoDB Queue instance.* Use for `opts.queue` when creating *MailTime* instance
698
-
699
- - `opts` {*object*} - Configuration object
700
- - `opts.db` {*Db*} - Required, Mongo's `Db` instance, like one returned from `MongoClient#db()`
701
- - `opts.prefix` {*string*} - Optional prefix for scope isolation; use when creating multiple `MailTime` instances within the single application
702
-
703
- ```js
704
- import { MailTime, MongoQueue } from 'mail-time';
705
- import { MongoClient } from 'mongodb';
706
-
707
- new MongoQueue({
708
- db: (await MongoClient.connect('mongodb://url')).db('database'),
709
- prefix: 'appMailQueue',
710
- });
711
- ```
712
-
713
- ### `sendMail(opts)`
714
-
715
- *Add email to the queue.* Returns `Promise<string>` unique email's `uuid`
716
-
717
- - `opts` {*object*} - Configuration object
718
- - `opts.sendAt` {*number*} - When email should be sent, default - `Date.now()`
719
- - `opts.template` {*string*} - Email specific template, this will override default template passed to `MailTime` constructor
720
- - `opts.concatSubject` {*string*} - Email specific concatenation subject, this will override default concatenation subject passed to `MailTime` constructor
721
- - `opts[key]` {*mix*} - Other custom and NodeMailer specific options, like `text`, `html` and `to`, [learn more here](https://nodemailer.com/message/). __Note:__ if [`attachments`](https://nodemailer.com/message/attachments/) are used via `path` โ€” file must exists on all micro-services servers
722
-
723
- ### `cancelMail(uuid)`
724
-
725
- *Remove email from queue.* Returns `Promise<boolean>` โ€” `true` if cancelled or `false` if not found, was sent, or was cancelled previously. Throws *Error*
388
+ `MailTime.Template` is a bundled responsive HTML envelope you can use as the default โ€” set it on the constructor or per-letter via `opts.template`.
726
389
 
727
- - `uuid` {*string*|*promise*} โ€” email's `uuid` returned from `.sendEmail()` method
390
+ ## API
728
391
 
729
- ```js
730
- import { mailQueue } from './mail-queue.js';
392
+ ### `new MailTime(opts)`
393
+
394
+ | Option | Type | Default | Notes |
395
+ | ----------------------------- | --------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
396
+ | `queue` | `MongoQueue \| RedisQueue \| PostgresQueue \| CustomQueue` | โ€” | **Required.** Storage adapter for letters. Custom adapters: see [docs/queue-api.md](https://github.com/veliovgroup/mail-time/blob/master/docs/queue-api.md). |
397
+ | `type` | `'server' \| 'client'` | `'server'` | `'client'` only enqueues โ€” no `transports` / `josk` required. |
398
+ | `transports` | `nodemailer.Transport[]` | โ€” | **Required for `server`**. Non-empty. |
399
+ | `josk` | `MailTimeJoSkOptions` | โ€” | **Required for `server`**. See [JoSk options](#josk-options) below. |
400
+ | `strategy` | `'backup' \| 'balancer'` | `'backup'` | Multi-SMTP rotation policy. |
401
+ | `failsToNext` | `number` | `4` | (`backup`) failures-in-a-row before rotating. |
402
+ | `retries` | `number` | `59` | Re-send attempts after first failure. Total attempts = `retries + 1` (defaults to 60). Legacy alias `maxTries` is honored when `retries` is absent: `new MailTime({ maxTries: N })` sets total attempts to `N`. |
403
+ | `retryDelay` | `number` (ms) | `60000` | Wait between attempts. |
404
+ | `keepHistory` | `boolean` | `false` | Keep sent/failed/cancelled rows. |
405
+ | `concatEmails` | `boolean \| { subject?: string }` | `false` | Fold same-`to` letters into one. Pass `{ subject: 'X' }` to set the folded-letter subject inline; the string supports the `{{count}}` placeholder and overrides `concatSubject`. |
406
+ | `concatSubject` | `string` | `'Multiple notifications'` | Subject when folded. Supports `{{count}}` for the folded letter count. |
407
+ | `concatDelimiter` | `string` | `'<hr>'` | Separator between folded bodies. |
408
+ | `concatDelay` | `number` (ms) | `60000` | Fold window. |
409
+ | `revolvingInterval` | `number` (ms) | `1536` | Queue iteration interval. |
410
+ | `mode` | `'one' \| 'batch'` | `'batch'` | `'batch'` claims every due row per tick; `'one'` claims a single row per tick. |
411
+ | `concurrency` | `number` | `1` | Parallel SMTPs per instance. The CAS on `isSending` prevents duplicate delivery. |
412
+ | `sendingTimeout` | `number` (ms) | `300000` | Window after which a stuck `isSending=true` row becomes eligible again. Must exceed worst-case SMTP roundtrip. |
413
+ | `verifyTransports` | `boolean` | `true` | Probe each transport via `transport.verify()` once at `ready()`. Failing transports are marked unusable, surfaced through `onError(error, null, { transportIndex, phase: 'verify' })`, and skipped during rotation/fallback. Throws from `ready()` if **every** transport fails. Transports without a `verify()` method are treated as healthy. |
414
+ | `template` | `string` | `'{{{html}}}'` | Default envelope. |
415
+ | `prefix` | `string` | `''` | Queue namespace. **Same** on every `client` and `server` for one logical queue; **different** per email class. Inherited by the queue adapter; JoSk scheduler uses `mailTimeQueue<prefix>`. |
416
+ | `from` | `string \| (transport) => string` | โ€” | Strongly recommended for spam-passing `From:` formatting. |
417
+ | `debug` | `boolean` | `false` | Verbose logs. |
418
+ | `onSent(email, info)` | `function` | โ€” | Called once the task is fully delivered. `email.mailOptions[i].accepted` lists every address that got through (across all attempts). |
419
+ | `onError(error, email, info)` | `function` | โ€” | Called once the retry budget is exhausted with at least one un-accepted recipient. `email.mailOptions[i].rejected` lists each un-delivered address with its last error. Also fires once per transport that fails `verify()` at startup with `email === null` and `info = { transportIndex, phase: 'verify' }`. |
420
+
421
+ ### JoSk options
422
+
423
+ `opts.josk` is passed to the underlying [`JoSk`](https://github.com/veliovgroup/josk) constructor. Useful keys:
424
+
425
+ | Key | Default | Notes |
426
+ | ------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------- |
427
+ | `adapter` | โ€” | Either a constructed adapter or a config object: `{ type: 'redis' \| 'mongo' \| 'postgres', client \| db, prefix?, resetOnInit?, useHashTags? }`. MailTime constructs the adapter from the config object. Set `useHashTags: true` on Redis/KeyDB Cluster. |
428
+ | `minRevolvingDelay` | `512` | Lower bound of poll window. |
429
+ | `maxRevolvingDelay` | `2048` | Upper bound. |
430
+ | `zombieTime` | `60000` | Re-claim if `queue.iterate()` runs longer than this. **Do not drop below 60s.** |
431
+ | `execute` | `'batch'` | JoSk scheduler batching; low impact for MailTime (one interval task per instance). |
432
+ | `concurrency` | `Infinity` | Cap overlapping JoSk handler runs on **this** process (`1` if ticks pile up). |
433
+ | `autoClear` | `false` | Remove orphan tasks from storage. |
434
+ | `lockOwnerId` | `josk-<uuid>` | Stable owner id; recommended per worker. |
435
+ | `onError(title, details)` | (logs to console) | Wire to your logger. |
436
+ | `onExecuted(uid, details)` | โ€” | Optional hook after each successful JoSk tick (observability). |
437
+
438
+ For deeper JoSk semantics, install the JoSk skill: **`npx skills add veliovgroup/josk`** (the same author).
439
+
440
+ ### Methods
441
+
442
+ - `sendMail(opts)` โ†’ `Promise<string>` uuid. Throws on missing `to` or on missing both `text` and `html`. Pass any nodemailer message option plus `sendAt` (Date or ms timestamp), `template`, `concatSubject`.
443
+ - `send(opts)` โ€” alias of `sendMail`.
444
+ - `cancelMail(uuidOrPromise)` โ†’ `Promise<boolean>`. Accepts the `uuid` or the `Promise<string>` from `sendMail`.
445
+ - `cancel(uuid)` โ€” alias of `cancelMail`.
446
+ - `ping()` โ†’ `Promise<{status, code, statusCode, error?}>`. Pings scheduler then queue.
447
+ - `ready()` โ†’ `Promise<MailTime>`. Awaits all startup work; rejects with `.cause` on storage failure.
448
+ - `destroy(opts?)` โ†’ `boolean` or `Promise<boolean>` when `{ drain: true }`. Stops scheduler. Idempotent. Use `destroy({ drain: true })` or `await drain()` after `destroy()` for graceful shutdown.
449
+ - `drain()` โ†’ `Promise<void>`. Resolves once every in-flight SMTP attempt finishes. Useful in tests and graceful-shutdown paths.
450
+
451
+ ### Queue constructors
452
+
453
+ | Constructor | Required option | Optional |
454
+ | ---------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
455
+ | `new RedisQueue({ client, prefix? })` | connected `redis@^4/^5` client with `watch()` + `multi()` | `prefix` โ€” inherited from `MailTime` when omitted. Redis Cluster prefixes must map to one hash slot. |
456
+ | `new MongoQueue({ db, prefix? })` | `db` from `MongoClient#db()` | `prefix` โ€” inherited from `MailTime` when omitted. Indexes auto-created on first `ready()`. |
457
+ | `new PostgresQueue({ client, prefix? })` | `pg.Pool` (recommended) or `pg.Client` | `prefix` โ€” inherited from `MailTime` when omitted. `mail_time_queue` table auto-migrated on first `ready()`. |
458
+
459
+ For custom adapters see [docs/queue-api.md](https://github.com/veliovgroup/mail-time/blob/master/docs/queue-api.md).
460
+
461
+ ### Module functions
462
+
463
+ - `mailTimePreset(name, overrides?)` โ†’ fresh MailTime constructor config. Deep-clones the named [preset](#settings-presets) and deep-merges your overrides (scalars win, nested `josk` composes). Throws on unknown `name` or non-object `overrides`.
464
+ - `presets` โ€” read-only `{ [name]: partialConfig }` map backing `mailTimePreset`.
465
+ - `presetNames` โ€” read-only array of preset names.
466
+
467
+ ### Static
468
+
469
+ - `MailTime.Template` โ€” get/set the default HTML envelope template.
470
+
471
+ ## Migration from 3.x
472
+
473
+ Full v4 highlights, adapter changes, and type exports live in [docs/migration-v3-v4.md](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v3-v4.md). Quick checklist:
474
+
475
+ 1. **Node โ‰ฅ 20.9.0**, Bun โ‰ฅ 1.1.0. Bump your runtime first.
476
+ 2. **Swap adapter imports** to the new `MongoQueue` / `RedisQueue` / `PostgresQueue` constructors.
477
+ 3. **Pass `josk`** โ€” it's now required for `type: 'server'`.
478
+ 4. **`josk.zombieTime` default raised to `60000` ms** (was `32786`). Set it explicitly if you relied on the old value.
479
+ 5. **Custom queue adapters** โ€” `update`'s claim guard now triggers on `{ isSending: true, sendingAt, tries }` (was `{ isSent: true, tries }`) and must include the stale-lock-recovery clause `(isSending === false OR sendingAt <= now - sendingTimeout)`. The iterate path must call `await mailTimeInstance.___dispatch(row)` instead of `___send` and honor `opts.limit` / `opts.sendingTimeout`. See [docs/queue-api.md](https://github.com/veliovgroup/mail-time/blob/master/docs/queue-api.md) and [`adapters/blank-example.js`](https://github.com/veliovgroup/mail-time/blob/master/adapters/blank-example.js).
480
+ 6. **Default behavior unchanged** โ€” `concurrency: 1` keeps the post-upgrade send rate identical to v3. Opt into parallel sends by raising `concurrency`.
481
+
482
+ New v4 surface to opt into: [`mailTimePreset`](#settings-presets), `concurrency`, `mode`, `sendingTimeout`, `drain()`, per-recipient retries, and the [AI agent skills](#ai-agent-skills) bundle.
731
483
 
732
- const uuid = await mailQueue.sendMail({
733
- to: 'johndoe@example.com',
734
- subject: 'Email subject',
735
- text: 'You have got email!',
736
- html: '<p>You have got email!</p>',
737
- });
484
+ ## Testing
738
485
 
739
- await mailQueue.cancelMail(uuid);
486
+ ```sh
487
+ npm install
488
+ # DEFAULT RUN โ€” needs Redis + Mongo + Postgres up locally
489
+ REDIS_URL="redis://127.0.0.1:6379" \
490
+ MONGO_URL="mongodb://127.0.0.1:27017/mail-time-test" \
491
+ PG_URL="postgres://127.0.0.1:5432/postgres" \
492
+ npm test
493
+
494
+ # Single suite
495
+ npm run test:redis
496
+ npm run test:mongo
497
+ npm run test:postgres
498
+
499
+ # Bun-native test runner (only Jest-shaped tests)
500
+ bun test ./test/jest
740
501
  ```
741
502
 
742
- ### `static MailTime.Template`
743
-
744
- Simple and bulletproof HTML template, see [its source](https://github.com/veliovgroup/mail-time/blob/master/template.html). Usage example:
745
-
746
- ```js
747
- import { MailTime, MongoQueue, RedisQueue } from 'mail-time';
748
-
749
- // Make it default
750
- const mailQueue = new MailTime({
751
- /* .. */
752
- template: MailTime.Template
753
- });
754
-
755
- // For single letter
756
- mailQueue.sendMail({
757
- /* .. */
758
- template: MailTime.Template
759
- });
760
- ```
503
+ `npm test` runs Jest unit tests, then Mocha integration tests, then TypeScript declaration tests. Jest coverage threshold is **85%** across statements, branches, functions, and lines. GitHub Actions runs the matrix against `redis@^4` and `redis@^5`.
761
504
 
762
- ### Template Example
505
+ ## Bun
763
506
 
764
- Pass custom template via `template` property to `.sendMail()` method
507
+ MailTime ships pure ESM with a generated CJS bundle. Both runtimes (Bun โ‰ฅ 1.1.0, Node โ‰ฅ 20.9.0) load it directly:
765
508
 
766
509
  ```js
767
- mailQueue.sendMail({
768
- to: 'user@gmail.com',
769
- userName: 'Mike',
770
- subject: 'Sign up confirmation',
771
- text: 'Hello {{userName}}, \r\n Thank you for registration \r\n Your login: {{to}}',
772
- html: '<div style="text-align: center"><h1>Hello {{userName}}</h1><p><ul><li>Thank you for registration</li><li>Your login: {{to}}</li></ul></p></div>',
773
- template: '<body>{{{html}}}</body>'
774
- });
510
+ import { MailTime } from 'mail-time'; // works in both
775
511
  ```
776
512
 
777
- ## Testing
513
+ Mixed clusters (some Node, some Bun) share one schedule under the same `prefix` โ€” the lease lives in storage, runtime-agnostic.
778
514
 
779
- 1. Clone this package
780
- 2. Start local or obtain URLs for remote MongoDB and Redis servers
781
- 3. In Terminal (*Console*) go to directory where package was cloned
782
- 4. Then run:
515
+ ## License
783
516
 
784
- ```shell
785
- # Before running tests make sure NODE_ENV === development
786
- # Install NPM dependencies
787
- npm install --save-dev
788
-
789
- # DEFAULT RUN
790
- REDIS_URL="redis://127.0.0.1:6379" MONGO_URL="mongodb://127.0.0.1:27017/npm-mail-time-test-001" npm test
791
-
792
- # OPTIONALLY RUN WITH CUSTOM DOMAIN
793
- EMAIL_DOMAIN="your-domain.com" REDIS_URL="redis://127.0.0.1:6379" MONGO_URL="mongodb://127.0.0.1:27017/npm-mail-time-test-001" npm test
794
-
795
- # IF SOME TESTS ARE FAILING: ENABLE DEBUG
796
- DEBUG="true" REDIS_URL="redis://127.0.0.1:6379" MONGO_URL="mongodb://127.0.0.1:27017/npm-mail-time-test-001" npm test
797
-
798
- # Be patient, tests are taking around 8 mins
799
- ```
517
+ [BSD-3-Clause](LICENSE).
800
518
 
801
- ## Support this project:
519
+ ## Support this project
802
520
 
803
- - 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
804
- - 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
805
- - Star on [GitHub](https://github.com/veliovgroup/mail-time)
806
- - Star on [NPM](https://www.npmjs.com/package/mail-time)
807
- - Star on [Atmosphere](https://atmospherejs.com/ostrio/mailer)
808
- - [Sponsor maintainer via GitHub](https://github.com/sponsors/dr-dimitru) โ€” support open source with one-time contribution or on a regular basis
809
- - [Sponsor veliovgroup via GitHub](https://github.com/sponsors/veliovgroup) โ€” support company behind this package
810
- - [Support via PayPal](https://paypal.me/veliovgroup) โ€” support our open source contributions
521
+ - Star on [GitHub](https://github.com/veliovgroup/mail-time) and [NPM](https://www.npmjs.com/package/mail-time).
522
+ - [Sponsor maintainer on GitHub](https://github.com/sponsors/dr-dimitru).
523
+ - [Sponsor veliovgroup on GitHub](https://github.com/sponsors/veliovgroup).
524
+ - [PayPal](https://paypal.me/veliovgroup).
525
+ - Try [โ˜„๏ธ meteor-files.com](https://meteor-files.com/?ref=github-mail-time-repo-footer).
526
+ - Try [โ–ฒ ostr.io](https://ostr.io?ref=github-mail-time-repo-footer) for server monitoring, web analytics, web-CRON, and SEO pre-rendering.