Bulletproof email queue for horizontally scaled Node.js & Bun apps. Built on top of nodemailer and josk. Single runtime dependency, ESM + CJS, full TypeScript declarations.
MailTime runs in one of two modes:
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.clientโ only enqueues emails. Use for app servers in a "dedicated mail micro-service" topology.
Many clients + one or more servers coexist behind the same prefix in the same store.
- ๐ข Horizontally scaled โ synchronize one queue across N processes/hosts/DCs.
- ๐ Multi-SMTP rotation โ
backup(failover) andbalancer(round-robin) strategies. - ๐ช Built-in retries โ storage-backed retry with per-letter transport pinning.
- ๐ฏ Per-recipient retries โ when a multi-
tosend is partially rejected, only the un-accepted addresses are retried; delivered ones never see a duplicate. - ๐ฎ Email concatenation โ fold same-
toemails arriving inside a window into one letter. - ๐๏ธ One-line setup โ built-in presets for
transactional,otp,newsletter,marketing,notifications,alerts. - ๐ข๏ธ Three first-party storages โ MongoDB, Redis, PostgreSQL. Plus a custom-adapter contract.
- ๐ฆ Bun โฅ 1.1.0 & Node โฅ 20.9.0 โ same code, both runtimes.
- ๐ค Ships with AI agent skills โ see AI agent skills below.
- ๐ Hand-tuned ESM + CJS + TypeScript declarations.
- ๐งช 95%+ Jest coverage (85% threshold enforced) + Mocha integration tests for every adapter.
|----------------| |------| |------------------|
| Other mailer | ------> | SMTP | ------> | ^_^ Happy user |
|----------------| |------| |------------------|
The scheme above works only as long as SMTP is up.
|----------------| \ / |------| |------------------|
| Other mailer | --X---> | SMTP | ------> | 0_o Disappointed |
|----------------| / \ |------| |------------------|
^- email lost in vain
MailTime keeps every letter in the queue until SMTP confirms delivery.
|----------------| / |------| |------------------|
| Mail Time | --X---> | SMTP | ------> | ^_^ Happy user |
|---^------------| / |------| |------^-----------|
\-------------/ ^- We will try later /
\- put it back into queue /
\----------Once connection is back ------/
backup falls over on failure; balancer round-robins:
|--------|
/--X--| SMTP 1 |
/ ^ |--------|
/ \--- Retry with next provider
|----------------|/ |--------| |------------------|
| Mail Time | ---X--> | SMTP 2 | /->| ^_^ Happy user |
|----------------|\ ^ |--------| / |------------------|
\ \--- Retry /
\ |--------| /
\---->| SMTP 3 |--/
|--------|
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.
|===================THE=CLUSTER===================| |=QUEUE=|
| |----------| |----------| |----------| | | | |--------|
| | MailTime | | MailTime | | MailTime | | | |-->| SMTP 1 |------\
| | Server 1 | | Server 2 | | Server 3 | | | | |--------| \
| |-----\----| |----\-----| |----\-----| | | | |-------------|
| \---------------\----------------\----------> | |--------| | ^_^ |
| | | |-->| SMTP 2 |-->| Happy users |
| Each "App Server" | | | |--------| |-------------|
| runs MailTime as a "Server" | | | /
| for the maximum durability | | | |--------| /
| | | |-->| SMTP 3 |-----/
| | | | |--------|
|=================================================| |=======|
For a dedicated mail machine (rDNS / PTR records), use type: 'client' on app servers and a single type: 'server' micro-service:
|===================THE=CLUSTER===================| |=QUEUE=| |===Mail=Time===|
| |----------| |----------| |----------| | | | | | |--------|
| | MailTime | | MailTime | | MailTime | | | | | Micro-service |-->| SMTP 1 |------\
| | Client 1 | | Client 2 | | Client 3 | | | | | running | |--------| \
| |-----\----| |----\-----| |----\-----| | | | | MailTime as | |-------------|
| \---------------\----------------\----------> | | "Server" only | |--------| | ^_^ |
| | | | | sending |-->| SMTP 2 |-->| Happy users |
| Each "App" runs MailTime as | | | | emails | |--------| |-------------|
| a "Client" only placing emails to the queue. | | <-------- | /
| | | --------> | |--------| /
| | | | | |-->| SMTP 3 |-----/
| | | | | | |--------|
|=================================================| |=======| |===============|
See docs/multi-instance.md and docs/dedicated-mail-host.md for full topologies.
npm install --save mail-time nodemailer
# pick at least one storage driver:
npm install --save redis # for RedisQueue
npm install --save mongodb # for MongoQueue
npm install --save pg # for PostgresQueue
# Bun:
bun add mail-time nodemailerNote
nodemailer and adapter drivers are peers (not bundled) so you can pin your own versions.
Important
Upgrading from v3? See Migration from 3.x and the full v4.0.0 release notes.
For Meteor.js usage see docs/meteor.md.
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.
# Install the MailTime skill into the current project:
npx skills add veliovgroup/mail-time
# Recommended: also install the JoSk skill โ MailTime is built on JoSk,
# and deep scheduler questions resolve through JoSk's contract.
npx skills add veliovgroup/joskThe npx skills CLI (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.
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 instead of hand-tuning every knob.
// ESM
import { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } from 'mail-time';
// CommonJS
const { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } = require('mail-time');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.
// transports.js
import nodemailer from 'nodemailer';
export const transports = [
nodemailer.createTransport({
host: 'smtp.example.com',
from: 'no-reply@example.com',
auth: { user: 'no-reply', pass: process.env.SMTP_PASS },
}),
];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.
// mail-queue.js โ transactional emails on Redis
import { MailTime, RedisQueue, mailTimePreset } from 'mail-time';
import { createClient } from 'redis';
import { transports } from './transports.js';
const redisClient = await createClient({ url: process.env.REDIS_URL }).connect();
const mailQueue = new MailTime(mailTimePreset('transactional', {
type: 'server',
prefix: 'app',
queue: new RedisQueue({ client: redisClient }),
josk: { adapter: { type: 'redis', client: redisClient } },
transports,
from: (t) => `"Awesome App" <${t.options.from}>`,
onSent(email, info) {
console.log('sent', email.uuid, info);
},
onError(error, email, info) {
console.error('failed', email.uuid, error, info);
},
}));
await mailQueue.ready();
export { mailQueue };Switching stores is one import change. The same pattern works with MongoQueue({ db }) + { type: 'mongo', db }, or PostgresQueue({ client: pgPool }) + { type: 'postgres', client: pgPool }.
import { mailQueue } from './mail-queue.js';
const uuid = await mailQueue.sendMail({
to: 'user@example.com',
subject: 'You\'ve got an email!',
text: 'Plain text body',
html: '<h1>HTML</h1><p>Styled body</p>',
});
// later โ cancel before sendAt:
await mailQueue.cancelMail(uuid); // true | falsesendMail returns a stable uuid you can store for cancellation. Pass any nodemailer message option โ to, subject, text, html, attachments, cc, bcc, custom headers, etc.
App servers that only enqueue need no transports, no josk โ just the queue. Use the same prefix as the server that drains the class.
import { MailTime, RedisQueue } from 'mail-time';
import { createClient } from 'redis';
const redisClient = await createClient({ url: process.env.REDIS_URL }).connect();
export const mailQueue = new MailTime({
type: 'client',
prefix: 'app',
queue: new RedisQueue({ client: redisClient }),
});process.on('SIGTERM', async () => {
await mailQueue.destroy({ drain: true }); // stop scheduler, then wait for in-flight SMTPs
});Or explicitly: mailQueue.destroy(); await mailQueue.drain();
destroy() is idempotent and stops the scheduler. Always call it from tests; pair with drain() when iterate-driven sends ran.
Queue storage and scheduler storage can be the same store or different ones. Use this matrix:
| Queue | Scheduler (josk) |
Best for |
|---|---|---|
| Postgres | Postgres | Multi-DC, mixed clocks, strict exactly-once. |
| Redis | Redis | High-throughput single-region. |
| Mongo | Mongo | Apps already on Mongo (especially Meteor). |
| Mongo | Redis | Durable letter storage + sub-second polling. |
| Redis | Mongo | Hot Redis letters + Mongo for scheduler. |
For split-store setups pass a different client to each:
const mailQueue = new MailTime({
prefix: 'app',
queue: new MongoQueue({ db }),
transports,
josk: {
adapter: { type: 'redis', client: redisClient },
},
/* ... */
});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.
import { MailTime, RedisQueue, mailTimePreset } from 'mail-time';
const mailTime = new MailTime(mailTimePreset('otp', {
prefix: 'otp',
queue: new RedisQueue({ client: redisClient }),
transports: [otpTransport],
josk: { adapter: { type: 'redis', client: redisClient } },
}));| Preset | Shape | Best for |
|---|---|---|
transactional |
retries: 30, retryDelay: 10s, concatEmails: false, concurrency: 1, josk.zombieTime: 120s |
Receipts, password resets, account changes, welcome emails. |
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. |
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. |
marketing |
retries: 10, retryDelay: 30s, concatEmails: false, concurrency: 5, josk.zombieTime: 3min |
Promotional / campaign blasts where each letter is unique. |
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. |
alerts |
retries: 20, retryDelay: 5s, snappy revolvingInterval: 1024 + jitter 256/1024, concurrency: 2, sendingTimeout: 60s |
Ops / admin alerts: monitoring, error reports, escalations. |
Presets are equally useful on type: 'client' instances โ keys that don't apply to the client role are simply ignored.
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 to keep the boilerplate to a single line per class.
- Same
prefixfor everyclientandserverthat share one logical queue. - Different
prefixper class so namespaces don't collide. - Never reuse
prefixacross two instances with differentconcatEmails,retryDelay, or other mail policy.
Full example wiring three classes (OTP / transactional / marketing) on one Redis connection, plus app-pod client setup, lives in docs/multi-instance.md.
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.
Full systemd unit + worker layout: docs/dedicated-mail-host.md.
Defaults fit moderate traffic in a single region. Reach for a preset first; tune individual knobs only when the preset doesn't cover your case. Full guide: docs/tuning.md.
| Option | Default | Change when |
|---|---|---|
mode |
'batch' |
'one' to claim a single row per tick (fairness over throughput across cluster nodes) |
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. |
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. |
revolvingInterval |
1536 ms |
Lower โ faster pickup; higher โ less scheduler I/O |
josk.minRevolvingDelay / maxRevolvingDelay |
512 / 2048 |
Lower both โ snappier polls, more storage load |
josk.zombieTime |
60000 |
Never below 60s. Iterate releases the JoSk lease as soon as scanning ends โ only a stalled storage scan can blow this. |
josk.concurrency |
Infinity |
Set 1 if scheduler ticks overlap while iterate still runs |
josk.execute |
'batch' |
Usually leave default; MailTime only registers one JoSk task per instance |
josk.lockOwnerId |
random | Set in production for observability |
retries / retryDelay |
59 / 60s |
retries is after the first attempt; default 59 means 60 total attempts. Per email class โ transactional shorter, marketing longer. |
concatEmails / concatDelay |
false / 60s |
On for notification batching; off for OTP and receipts |
prefix |
'' |
Same on all client + server for one queue; different only per email class / shard |
For deeper JoSk semantics (lease lifecycle, scheduler adapters, recurring tasks), install the JoSk skill: npx skills add veliovgroup/josk.
Two Mustache-like placeholder forms:
{{key}}โ string interpolation, strips HTML from the value (safe for plain text).{{{key}}}โ raw HTML interpolation.
Every sendMail option is available inside text, html, and the wrapping template:
const layouts = {
envelope: `<html><body>{{{html}}}<footer>Sent to @{{username}} ({{to}})</footer></body></html>`,
otp: {
text: 'Hello @{{username}}! Your code: {{code}}',
html: '<h1>Sign-in</h1><p>Hello <b>@{{username}}</b></p><pre><code>{{code}}</code></pre>',
},
};
const mailQueue = new MailTime({
/* ... */
template: layouts.envelope,
});
await mailQueue.sendMail({
to: 'user@example.com',
subject: 'Sign-in code',
username: 'mike',
code: 'A1B2-C3D4',
text: layouts.otp.text,
html: layouts.otp.html,
});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.
| Option | Type | Default | Notes |
|---|---|---|---|
queue |
MongoQueue | RedisQueue | PostgresQueue | CustomQueue |
โ | Required. Storage adapter for letters. Custom adapters: see docs/queue-api.md. |
type |
'server' | 'client' |
'server' |
'client' only enqueues โ no transports / josk required. |
transports |
nodemailer.Transport[] |
โ | Required for server. Non-empty. |
josk |
MailTimeJoSkOptions |
โ | Required for server. See JoSk options below. |
strategy |
'backup' | 'balancer' |
'backup' |
Multi-SMTP rotation policy. |
failsToNext |
number |
4 |
(backup) failures-in-a-row before rotating. |
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. |
retryDelay |
number (ms) |
60000 |
Wait between attempts. |
keepHistory |
boolean |
false |
Keep sent/failed/cancelled rows. |
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. |
concatSubject |
string |
'Multiple notifications' |
Subject when folded. Supports {{count}} for the folded letter count. |
concatDelimiter |
string |
'<hr>' |
Separator between folded bodies. |
concatDelay |
number (ms) |
60000 |
Fold window. |
revolvingInterval |
number (ms) |
1536 |
Queue iteration interval. |
mode |
'one' | 'batch' |
'batch' |
'batch' claims every due row per tick; 'one' claims a single row per tick. |
concurrency |
number |
1 |
Parallel SMTPs per instance. The CAS on isSending prevents duplicate delivery. |
sendingTimeout |
number (ms) |
300000 |
Window after which a stuck isSending=true row becomes eligible again. Must exceed worst-case SMTP roundtrip. |
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. |
template |
string |
'{{{html}}}' |
Default envelope. |
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>. |
from |
string | (transport) => string |
โ | Strongly recommended for spam-passing From: formatting. |
debug |
boolean |
false |
Verbose logs. |
onSent(email, info) |
function |
โ | Called once the task is fully delivered. email.mailOptions[i].accepted lists every address that got through (across all attempts). |
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' }. |
opts.josk is passed to the underlying JoSk constructor. Useful keys:
| Key | Default | Notes |
|---|---|---|
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. |
minRevolvingDelay |
512 |
Lower bound of poll window. |
maxRevolvingDelay |
2048 |
Upper bound. |
zombieTime |
60000 |
Re-claim if queue.iterate() runs longer than this. Do not drop below 60s. |
execute |
'batch' |
JoSk scheduler batching; low impact for MailTime (one interval task per instance). |
concurrency |
Infinity |
Cap overlapping JoSk handler runs on this process (1 if ticks pile up). |
autoClear |
false |
Remove orphan tasks from storage. |
lockOwnerId |
josk-<uuid> |
Stable owner id; recommended per worker. |
onError(title, details) |
(logs to console) | Wire to your logger. |
onExecuted(uid, details) |
โ | Optional hook after each successful JoSk tick (observability). |
For deeper JoSk semantics, install the JoSk skill: npx skills add veliovgroup/josk (the same author).
sendMail(opts)โPromise<string>uuid. Throws on missingtoor on missing bothtextandhtml. Pass any nodemailer message option plussendAt(Date or ms timestamp),template,concatSubject.send(opts)โ alias ofsendMail.cancelMail(uuidOrPromise)โPromise<boolean>. Accepts theuuidor thePromise<string>fromsendMail.cancel(uuid)โ alias ofcancelMail.ping()โPromise<{status, code, statusCode, error?}>. Pings scheduler then queue.ready()โPromise<MailTime>. Awaits all startup work; rejects with.causeon storage failure.destroy(opts?)โbooleanorPromise<boolean>when{ drain: true }. Stops scheduler. Idempotent. Usedestroy({ drain: true })orawait drain()afterdestroy()for graceful shutdown.drain()โPromise<void>. Resolves once every in-flight SMTP attempt finishes. Useful in tests and graceful-shutdown paths.
| Constructor | Required option | Optional |
|---|---|---|
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. |
new MongoQueue({ db, prefix? }) |
db from MongoClient#db() |
prefix โ inherited from MailTime when omitted. Indexes auto-created on first ready(). |
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(). |
For custom adapters see docs/queue-api.md.
mailTimePreset(name, overrides?)โ fresh MailTime constructor config. Deep-clones the named preset and deep-merges your overrides (scalars win, nestedjoskcomposes). Throws on unknownnameor non-objectoverrides.presetsโ read-only{ [name]: partialConfig }map backingmailTimePreset.presetNamesโ read-only array of preset names.
MailTime.Templateโ get/set the default HTML envelope template.
Full v4 highlights, adapter changes, and type exports live in docs/migration-v3-v4.md. Quick checklist:
- Node โฅ 20.9.0, Bun โฅ 1.1.0. Bump your runtime first.
- Swap adapter imports to the new
MongoQueue/RedisQueue/PostgresQueueconstructors. - Pass
joskโ it's now required fortype: 'server'. josk.zombieTimedefault raised to60000ms (was32786). Set it explicitly if you relied on the old value.- 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 callawait mailTimeInstance.___dispatch(row)instead of___sendand honoropts.limit/opts.sendingTimeout. See docs/queue-api.md andadapters/blank-example.js. - Default behavior unchanged โ
concurrency: 1keeps the post-upgrade send rate identical to v3. Opt into parallel sends by raisingconcurrency.
New v4 surface to opt into: mailTimePreset, concurrency, mode, sendingTimeout, drain(), per-recipient retries, and the AI agent skills bundle.
npm install
# DEFAULT RUN โ needs Redis + Mongo + Postgres up locally
REDIS_URL="redis://127.0.0.1:6379" \
MONGO_URL="mongodb://127.0.0.1:27017/mail-time-test" \
PG_URL="postgres://127.0.0.1:5432/postgres" \
npm test
# Single suite
npm run test:redis
npm run test:mongo
npm run test:postgres
# Bun-native test runner (only Jest-shaped tests)
bun test ./test/jestnpm 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.
MailTime ships pure ESM with a generated CJS bundle. Both runtimes (Bun โฅ 1.1.0, Node โฅ 20.9.0) load it directly:
import { MailTime } from 'mail-time'; // works in bothMixed clusters (some Node, some Bun) share one schedule under the same prefix โ the lease lives in storage, runtime-agnostic.
- Star on GitHub and NPM.
- Sponsor maintainer on GitHub.
- Sponsor veliovgroup on GitHub.
- PayPal.
- Try โ๏ธ meteor-files.com.
- Try โฒ ostr.io for server monitoring, web analytics, web-CRON, and SEO pre-rendering.