mail-time 1.4.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,65 +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 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.
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)
9
9
 
10
- Every `MailTime` instance can have `type` configured as *Server* or *Client*.
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
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.
12
+ `MailTime` runs in one of two modes:
13
13
 
14
- ## ToC
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.
15
16
 
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 Clusters](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 example](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#basic-usage)
24
- - [API](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#api)
25
- - [*Constructor*](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#new-mailtimeopts-constructor)
26
- - [`.send()`](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#sendmailopts--callback)
27
- - [Default Template](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#static-mailtimetemplate)
28
- - [Custom Templates](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#template-example)
29
- - [~92% tests coverage](https://github.com/veliovgroup/mail-time?tab=readme-ov-file#testing)
17
+ Many clients + one or more servers coexist behind the same `prefix` in the same store.
30
18
 
31
- ## Main features:
32
-
33
- - 👨‍🔬 ~92% tests coverage;
34
- - 📦 Two simple dependencies, written from scratch for top performance;
35
- - 🏢 Synchronize email queue across multiple (horizontally scaled) servers;
36
- - 💪 Bulletproof design, built-in retries.
19
+ ## Features
37
20
 
38
- ## How does it work?
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.
39
32
 
40
- Redundant solution for email transmission.
33
+ ## How it works
41
34
 
42
35
  ### Single point of failure
43
36
 
44
- Issue - mitigate a single point of failure:
45
-
46
37
  ```ascii
47
38
  |----------------| |------| |------------------|
48
39
  | Other mailer | ------> | SMTP | ------> | ^_^ Happy user |
49
40
  |----------------| |------| |------------------|
50
41
 
51
- The scheme above will work as long as SMTP service is available
52
- or connection between your server and SMPT is up. Once network
53
- failure occurs or SMTP service is down - users won't be happy
42
+ The scheme above works only as long as SMTP is up.
54
43
 
55
44
  |----------------| \ / |------| |------------------|
56
45
  | Other mailer | --X---> | SMTP | ------> | 0_o Disappointed |
57
46
  |----------------| / \ |------| |------------------|
58
47
  ^- email lost in vain
59
48
 
60
- Single SMTP solution may work in case of network or other failures
61
- As long as MailTime has not received confirmation what email is sent
62
- 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.
63
50
 
64
51
  |----------------| / |------| |------------------|
65
52
  | Mail Time | --X---> | SMTP | ------> | ^_^ Happy user |
@@ -71,7 +58,7 @@ it will keep the letter in the queue and retry to send it again
71
58
 
72
59
  ### Multiple SMTP providers
73
60
 
74
- Rotate or backup email transports by using multiple SMTP providers
61
+ `backup` falls over on failure; `balancer` round-robins:
75
62
 
76
63
  ```ascii
77
64
  |--------|
@@ -87,23 +74,19 @@ Rotate or backup email transports by using multiple SMTP providers
87
74
  |--------|
88
75
  ```
89
76
 
90
- ### Sending emails from cluster of servers
91
-
92
- It is common to have horizontally scaled "Cluster" of servers for load-balancing and for durability.
93
-
94
- 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.
77
+ ### Sending emails from a cluster
95
78
 
96
- 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.
97
80
 
98
81
  ```ascii
99
82
  |===================THE=CLUSTER===================| |=QUEUE=|
100
83
  | |----------| |----------| |----------| | | | |--------|
101
- | | App | | App | | App | | | |-->| SMTP 1 |------\
84
+ | | MailTime | | MailTime | | MailTime | | | |-->| SMTP 1 |------\
102
85
  | | Server 1 | | Server 2 | | Server 3 | | | | |--------| \
103
86
  | |-----\----| |----\-----| |----\-----| | | | |-------------|
104
87
  | \---------------\----------------\----------> | |--------| | ^_^ |
105
88
  | | | |-->| SMTP 2 |-->| Happy users |
106
- | Each "App Server" or "Cluster Node" | | | |--------| |-------------|
89
+ | Each "App Server" | | | |--------| |-------------|
107
90
  | runs MailTime as a "Server" | | | /
108
91
  | for the maximum durability | | | |--------| /
109
92
  | | | |-->| SMTP 3 |-----/
@@ -111,17 +94,17 @@ For the maximum durability and agility each Application Server can run MailTime
111
94
  |=================================================| |=======|
112
95
  ```
113
96
 
114
- 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:
115
98
 
116
99
  ```ascii
117
100
  |===================THE=CLUSTER===================| |=QUEUE=| |===Mail=Time===|
118
101
  | |----------| |----------| |----------| | | | | | |--------|
119
- | | App | | App | | App | | | | | Micro-service |-->| SMTP 1 |------\
120
- | | Server 1 | | Server 2 | | Server 3 | | | | | running | |--------| \
102
+ | | MailTime | | MailTime | | MailTime | | | | | Micro-service |-->| SMTP 1 |------\
103
+ | | Client 1 | | Client 2 | | Client 3 | | | | | running | |--------| \
121
104
  | |-----\----| |----\-----| |----\-----| | | | | MailTime as | |-------------|
122
105
  | \---------------\----------------\----------> | | "Server" only | |--------| | ^_^ |
123
106
  | | | | | sending |-->| SMTP 2 |-->| Happy users |
124
- | Each "App Server" runs MailTime as | | | | emails | |--------| |-------------|
107
+ | Each "App" runs MailTime as | | | | emails | |--------| |-------------|
125
108
  | a "Client" only placing emails to the queue. | | <-------- | /
126
109
  | | | --------> | |--------| /
127
110
  | | | | | |-->| SMTP 3 |-----/
@@ -129,332 +112,415 @@ To split roles MailTime can run on a dedicated machine as micro-service. This ca
129
112
  |=================================================| |=======| |===============|
130
113
  ```
131
114
 
132
- ## Features
133
-
134
- - Queue - Managed via MongoDB, will survive server reboots and failures
135
- - Support for multiple server setups - "Cluster", Phusion Passenger instances, Load Balanced solutions, etc.
136
- - Emails concatenation by addressee email - Reduce amount of sent emails to a single user with concatenation, and avoid mistakenly doubled emails
137
- - When concatenation is enabled - Same emails won't be sent twice, if for any reason, due to bad logic or application failure emails are sent twice or more times - this is solution to solve this annoying behavior
138
- - Balancing for multiple nodemailer's transports, two modes - `backup` and `balancing`. This is the most useful feature — allowing to reduce the cost of SMTP services and add extra layer of durability. If one transport failing to send an email `mail-time` will switch to the next one
139
- - Sending retries for network and other failures
140
- - Templating support with [Mustache](https://mustache.github.io/)-like placeholders
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.
141
116
 
142
117
  ## Installation
143
118
 
144
- To implement Server functionality — as a first step install `nodemailer`, although this package meant to be used with `nodemailer`, it's not added as the dependency, as `nodemailer` not needed by Client, and you're free to choose `nodemailer`'s version to fit your 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
145
125
 
146
- ```shell
147
- npm install --save nodemailer
126
+ # Bun:
127
+ bun add mail-time nodemailer
148
128
  ```
149
129
 
150
- Install *MailTime* package:
130
+ > [!NOTE]
131
+ > `nodemailer` and adapter drivers are peers (not bundled) so you can pin your own versions.
132
+
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).
135
+
136
+ For Meteor.js usage see [docs/meteor.md](https://github.com/veliovgroup/mail-time/blob/master/docs/meteor.md).
137
+
138
+ ## AI agent skills
151
139
 
152
- ```shell
153
- # for node@>=14.20.0
154
- npm install --save mail-time
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.
155
141
 
156
- # for node@<14.20.0
157
- npm install --save mail-time@=1.3.4
142
+ ```sh
143
+ # Install the MailTime skill into the current project:
144
+ npx skills add veliovgroup/mail-time
158
145
 
159
- # for node@<8.9.0
160
- npm install --save mail-time@=0.1.7
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
161
149
  ```
162
150
 
163
- ## Basic usage
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.
164
152
 
165
- Require package:
153
+ ## Quick start
154
+
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.
156
+
157
+ ### 1. Import
166
158
 
167
159
  ```js
168
- // import as ES Module
169
- import MailTime from 'mail-time';
160
+ // ESM
161
+ import { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } from 'mail-time';
170
162
 
171
- // requires as CommonJS
172
- const MailTime = require('mail-time');
163
+ // CommonJS
164
+ const { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } = require('mail-time');
173
165
  ```
174
166
 
175
- Create nodemailer's transports, for details see [`nodemailer` docs](https://github.com/nodemailer/nodemailer/tree/v2#setting-up):
167
+ ### 2. Create nodemailer transports
168
+
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.
176
170
 
177
171
  ```js
172
+ // transports.js
178
173
  import nodemailer from 'nodemailer';
179
- // Use DIRECT transport
180
- // and enable sending email from localhost
181
- // install "nodemailer-direct-transport" NPM package:
182
- import directTransport from 'nodemailer-direct-transport';
183
-
184
- const transports = [];
185
- const directTransportOpts = {
186
- pool: false,
187
- direct: true,
188
- name: 'mail.example.com',
189
- from: 'no-reply@example.com',
190
- };
191
- transports.push(nodemailer.createTransport(directTransport(directTransportOpts)));
192
- // IMPORTANT: Add `.options` to a newly created transport,
193
- // this is necessary to make sure options are available to MailTime package:
194
- transports[0].options = directTransportOpts;
195
-
196
- // Private SMTP
197
- transports.push(nodemailer.createTransport({
198
- host: 'smtp.example.com',
199
- from: 'no-reply@example.com',
200
- auth: {
201
- user: 'no-reply',
202
- pass: 'xxx'
203
- },
204
- }));
205
174
 
206
- // Google Apps SMTP
207
- transports.push(nodemailer.createTransport({
208
- host: 'smtp.gmail.com',
209
- from: 'no-reply@mail.example.com',
210
- auth: {
211
- user: 'no-reply@mail.example.com',
212
- pass: 'xxx'
213
- },
214
- }));
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
+ ];
182
+ ```
215
183
 
216
- // Mailing service (SparkPost as example)
217
- transports.push(nodemailer.createTransport({
218
- host: 'smtp.sparkpostmail.com',
219
- port: 587,
220
- from: 'no-reply@mail2.example.com',
221
- auth: {
222
- user: 'SMTP_Injection',
223
- pass: 'xxx'
184
+ ### 3. Initialize MailTime with a preset
185
+
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.
187
+
188
+ ```js
189
+ // mail-queue.js — transactional emails on Redis
190
+ import { MailTime, RedisQueue, mailTimePreset } from 'mail-time';
191
+ import { createClient } from 'redis';
192
+ import { transports } from './transports.js';
193
+
194
+ const redisClient = await createClient({ url: process.env.REDIS_URL }).connect();
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 } },
201
+ transports,
202
+ from: (t) => `"Awesome App" <${t.options.from}>`,
203
+ onSent(email, info) {
204
+ console.log('sent', email.uuid, info);
205
+ },
206
+ onError(error, email, info) {
207
+ console.error('failed', email.uuid, error, info);
224
208
  },
225
209
  }));
210
+
211
+ await mailQueue.ready();
212
+ export { mailQueue };
226
213
  ```
227
214
 
228
- As the next step initiate `mail-time` in the *Server* mode, it will be able to __send__ and __add__ emails to the queue. Connecting to a MongoDB before initiating `new MailTime` instance:
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 }`.
216
+
217
+ ### 4. Send and cancel
229
218
 
230
219
  ```js
231
- const MailTime = require('mail-time');
232
- const MongoClient = require('mongodb').MongoClient;
233
-
234
- const dbName = 'databaseName';
235
-
236
- // Use MONGO_URL environment variable to store connection string to MongoDB
237
- // example: "MONGO_URL=mongodb://127.0.0.1:27017/myapp node mail-micro-service.js"
238
- MongoClient.connect(process.env.MONGO_URL, (error, client) => {
239
- const db = client.db(dbName);
240
-
241
- const mailQueue = new MailTime({
242
- db, // MongoDB
243
- type: 'server',
244
- strategy: 'balancer', // Transports will be used in round robin chain
245
- transports,
246
- from(transport) {
247
- // To pass spam-filters `from` field should be correctly set
248
- // for each transport, check `transport` object for more options
249
- return `"Awesome App" <${transport.options.from}>`;
250
- },
251
- concatEmails: true, // Concatenate emails to the same addressee
252
- concatDelimiter: '<h1>{{{subject}}}</h1>', // Start each concatenated email with it's own subject
253
- template: MailTime.Template // Use default template
254
- });
220
+ import { mailQueue } from './mail-queue.js';
221
+
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>',
255
227
  });
228
+
229
+ // later — cancel before sendAt:
230
+ await mailQueue.cancelMail(uuid); // true | false
256
231
  ```
257
232
 
258
- 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:
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.
259
234
 
260
- ```js
261
- const MailTime = require('mail-time');
262
- const MongoClient = require('mongodb').MongoClient;
235
+ ### 5. Client-only mode
263
236
 
264
- const dbName = 'databaseName';
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.
265
238
 
266
- MongoClient.connect(process.env.MONGO_URL, (error, client) => {
267
- const db = client.db(dbName);
239
+ ```js
240
+ import { MailTime, RedisQueue } from 'mail-time';
241
+ import { createClient } from 'redis';
242
+
243
+ const redisClient = await createClient({ url: process.env.REDIS_URL }).connect();
268
244
 
269
- const mailQueue = new MailTime({
270
- db,
271
- type: 'client',
272
- strategy: 'balancer', // Transports will be used in round robin chain
273
- concatEmails: true // Concatenate emails to the same address
274
- });
245
+ export const mailQueue = new MailTime({
246
+ type: 'client',
247
+ prefix: 'app',
248
+ queue: new RedisQueue({ client: redisClient }),
275
249
  });
276
250
  ```
277
251
 
278
- Send email example:
252
+ ### 6. Shutdown
279
253
 
280
254
  ```js
281
- mailQueue.sendMail({
282
- to: 'user@gmail.com',
283
- subject: 'You\'ve got an email!',
284
- text: 'Plain text message',
285
- 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
286
257
  });
287
258
  ```
288
259
 
289
- ### Two `MailTime` instances usage example
260
+ Or explicitly: `mailQueue.destroy(); await mailQueue.drain();`
261
+
262
+ `destroy()` is idempotent and stops the scheduler. Always call it from tests; pair with `drain()` when iterate-driven sends ran.
263
+
264
+ ## Storage layouts
290
265
 
291
- Create two `MailTime` instances with different settings.
266
+ Queue storage and scheduler storage can be the same store or different ones. Use this matrix:
267
+
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. |
275
+
276
+ For split-store setups pass a different client to each:
292
277
 
293
278
  ```js
294
- // CREATE mailQueue FOR NON-URGENT EMAILS WHICH IS OKAY TO CONCATENATE
295
279
  const mailQueue = new MailTime({
296
- db: db,
297
- interval: 35,
298
- strategy: 'backup',
299
- failsToNext: 1,
300
- concatEmails: true,
301
- concatThrottling: 16,
302
- zombieTime: 120000
280
+ prefix: 'app',
281
+ queue: new MongoQueue({ db }),
282
+ transports,
283
+ josk: {
284
+ adapter: { type: 'redis', client: redisClient },
285
+ },
286
+ /* ... */
303
287
  });
288
+ ```
304
289
 
305
- // CREATE mailInstantQueue FOR TRANSACTIONAL EMAILS AND ALERTS
306
- const mailInstantQueue = new MailTime({
307
- db: db,
308
- prefix: 'instant',
309
- interval: 2,
310
- strategy: 'backup',
311
- failsToNext: 1,
312
- concatEmails: false,
313
- zombieTime: 20000
314
- });
290
+ ## Settings presets
315
291
 
316
- mailQueue.sendMail({
317
- to: 'user@gmail.com',
318
- subject: 'You\'ve got an email!',
319
- text: 'Plain text message',
320
- html: '<h1>HTML</h1><p>Styled message</p>'
321
- });
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.
322
293
 
323
- mailInstantQueue.sendMail({
324
- to: 'user@gmail.com',
325
- subject: 'Sign in request',
326
- text: 'Your OTP login code: xxxx:',
327
- html: '<h1>Code:</h1><code>XXXX</code>'
328
- });
294
+ ```js
295
+ import { MailTime, RedisQueue, mailTimePreset } from 'mail-time';
296
+
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
+ }));
329
303
  ```
330
304
 
331
- ### Passing variables to the template
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. |
332
313
 
333
- All options passed to the `.sendMail()` method is available inside `text`, `html`, and global templates
314
+ Presets are equally useful on `type: 'client'` instances — keys that don't apply to the client role are simply ignored.
334
315
 
335
- ```js
336
- const templates = {
337
- 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>',
338
- signInCode: {
339
- text: 'Hello @{{username}}! Here\'s your login code: {{code}}',
340
- html: `<h1>Sign-in request</h1><p>Hello @{{username}}! <p>Copy your login code below:</p> <pre><code>{{code}}</code></pre>`
341
- }
342
- };
316
+ ## Multiple instances (recommended)
343
317
 
344
- const mailQueue = new MailTime({
345
- db: db,
346
- template: templates.global
347
- });
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.
348
319
 
349
- mailQueue.sendMail({
350
- to: 'user@gmail.com',
351
- subject: 'Sign-in request',
352
- username: 'johndoe',
353
- code: 'XXXXX-YY',
354
- text: templates.signInCode.text,
355
- html: templates.signInCode.html
356
- });
357
- ```
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.
358
323
 
359
- ## API
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).
325
+
326
+ ## Dedicated mail host
327
+
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.
329
+
330
+ Full systemd unit + worker layout: [docs/dedicated-mail-host.md](https://github.com/veliovgroup/mail-time/blob/master/docs/dedicated-mail-host.md).
331
+
332
+ ## Tuning
333
+
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).
360
335
 
361
- All available constructor options and `.sendMail()` method API overview
362
-
363
- ### `new MailTime(opts)` constructor
364
-
365
- - `opts` {*Object*} - Configuration object
366
- - `opts.db` {*Db*} - [Required] Mongo's `Db` instance. For example returned in callback of `MongoClient.connect()`
367
- - `opts.type` {*String*} - [Optional] `client` or `server`, default - `server`
368
- - `opts.from` {*Function*} - [Optional] A function which returns *String* of `from` field, format: `"MyApp" <user@example.com>`
369
- - `opts.transports` {*Array*} - [Optional] An array of `nodemailer`'s transports, returned from `nodemailer.createTransport({})`
370
- - `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
371
- - `opts.failsToNext` {*Number*} - [Optional] After how many failed "send attempts" switch to next transport, applied only for `backup` strategy, default - `4`
372
- - `opts.prefix` {*String*} - [Optional] Use unique prefixes to create multiple `MailTime` instances on same MongoDB
373
- - `opts.maxTries` {*Number*} - [Optional] How many times resend failed emails, default - `60`
374
- - `opts.interval` {*Number*} - [Optional] Interval in *seconds* between send re-tries, default - `60`
375
- - `opts.zombieTime` {*Number*} - [Optional] Time in *milliseconds*, after this period - pending email will be interpreted as "*zombie*". This parameter allows to rescue pending email from "*zombie* mode" in case when: server was rebooted, exception during runtime was thrown, or caused by bad logic, default - `32786`. This option is used by package itself and passed directly to [`JoSk` package](https://github.com/veliovgroup/josk#api)
376
- - `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`
377
- - `opts.concatEmails` {*Boolean*} - [Optional] Concatenate email by `to` field, default - `false`
378
- - `opts.concatSubject` {*String*} - [Optional] Email subject used in concatenated email, default - `Multiple notifications`
379
- - `opts.concatDelimiter` {*String*} - [Optional] HTML or plain string delimiter used between concatenated email, default - `<hr>`
380
- - `opts.concatThrottling` {*Number*} - [Optional] Time in *seconds* while emails are waiting to be concatenated, default - `60`
381
- - `opts.revolvingInterval` {*Number*} - [Optional] Interval in *milliseconds* in between queue checks, default - `256`. Recommended value — between `opts.minRevolvingDelay` and `opts.maxRevolvingDelay`
382
- - `opts.minRevolvingDelay` {*Number*} - [Optional] Minimum revolving delay — the minimum delay between tasks executions in *milliseconds*, default - `64`. This option is passed directly to [`JoSk` package](https://github.com/veliovgroup/josk#api)
383
- - `opts.maxRevolvingDelay` {*Number*} - [Optional] Maximum revolving delay — the maximum delay between tasks executions in *milliseconds*, default - `256`. This option is passed directly to [`JoSk` package](https://github.com/veliovgroup/josk#api)
384
- - `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
385
-
386
- ### `sendMail(opts [, callback])`
387
-
388
- - Alias - `send()`
389
- - `opts` {*Object*} - Configuration object
390
- - `opts.sendAt` {*Date*} - When email should be sent, default - `new Date()` use with caution on multi-server setup at different location with the different time-zones
391
- - `opts.template` - Email specific template, this will override default template passed to `MailTime` constructor
392
- - `opts.concatSubject` - Email specific concatenation subject, this will override default concatenation subject passed to `MailTime` constructor
393
- - `opts[key]` {*Mix*} - Other custom and NodeMailer specific options, like `text`, `html` and `to`, [learn more here](https://github.com/nodemailer/nodemailer/tree/v2#e-mail-message-fields). Note `attachments` should work only via `path`, and file must exists on all micro-services servers
394
- - `callback` {*Function*} - Callback called after the email was sent or failed to be sent. __Do not use on multi-server setup__
395
-
396
- ### `static MailTime.Template`
397
-
398
- Simple and bulletproof HTML template, see [its source](https://github.com/veliovgroup/mail-time/blob/master/template.html). Usage example:
336
+ ### Option reference (when to touch)
337
+
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 |
352
+
353
+ For deeper JoSk semantics (lease lifecycle, scheduler adapters, recurring tasks), install the JoSk skill: **`npx skills add veliovgroup/josk`**.
354
+
355
+ ## Templates
356
+
357
+ Two Mustache-like placeholder forms:
358
+
359
+ - `{{key}}` — string interpolation, strips HTML from the value (safe for plain text).
360
+ - `{{{key}}}` — raw HTML interpolation.
361
+
362
+ Every `sendMail` option is available inside `text`, `html`, and the wrapping template:
399
363
 
400
364
  ```js
401
- const MailTime = require('mail-time');
402
- // Make it default
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
+ };
372
+
403
373
  const mailQueue = new MailTime({
404
- db: db, // MongoDB
405
- /* .. */
406
- template: MailTime.Template
374
+ /* ... */
375
+ template: layouts.envelope,
407
376
  });
408
377
 
409
- // For single letter
410
- mailQueue.sendMail({
411
- to: 'user@gmail.com',
412
- /* .. */
413
- template: MailTime.Template
378
+ await mailQueue.sendMail({
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,
414
385
  });
415
386
  ```
416
387
 
417
- ### Template Example
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`.
418
389
 
419
- Pass custom template via `template` property to `.sendMail()` method
390
+ ## API
420
391
 
421
- ```js
422
- mailQueue.sendMail({
423
- to: 'user@gmail.com',
424
- userName: 'Mike',
425
- subject: 'Sign up confirmation',
426
- text: 'Hello {{userName}}, \r\n Thank you for registration \r\n Your login: {{to}}',
427
- 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>',
428
- template: '<body>{{{html}}}</body>'
429
- });
430
- ```
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.
431
483
 
432
484
  ## Testing
433
485
 
434
- 1. Clone this package
435
- 2. In Terminal (*Console*) go to directory where package is cloned
436
- 3. Then run:
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
501
+ ```
437
502
 
438
- Test NPM package:
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`.
439
504
 
440
- ```shell
441
- # Before run tests make sure NODE_ENV === development
442
- # Install NPM dependencies
443
- npm install --save-dev
505
+ ## Bun
444
506
 
445
- # Before run tests you need to have running MongoDB
446
- DEBUG="true" EMAIL_DOMAIN="example.com" MONGO_URL="mongodb://127.0.0.1:27017/npm-mail-time-test-001" npm test
507
+ MailTime ships pure ESM with a generated CJS bundle. Both runtimes (Bun ≥ 1.1.0, Node ≥ 20.9.0) load it directly:
447
508
 
448
- # Be patient, tests are taking around 2 mins
509
+ ```js
510
+ import { MailTime } from 'mail-time'; // works in both
449
511
  ```
450
512
 
451
- ## Support this project:
513
+ Mixed clusters (some Node, some Bun) share one schedule under the same `prefix` — the lease lives in storage, runtime-agnostic.
514
+
515
+ ## License
516
+
517
+ [BSD-3-Clause](LICENSE).
518
+
519
+ ## Support this project
452
520
 
453
- - 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
454
- - 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
455
- - Star on [GitHub](https://github.com/veliovgroup/mail-time)
456
- - Star on [NPM](https://www.npmjs.com/package/mail-time)
457
- - Star on [Atmosphere](https://atmospherejs.com/ostrio/mailer)
458
- - [Sponsor maintainer via GitHub](https://github.com/sponsors/dr-dimitru) — support open source with one-time contribution or on a regular basis
459
- - [Sponsor veliovgroup via GitHub](https://github.com/sponsors/veliovgroup) — support company behind this package
460
- - [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.