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. Per-row leases let one worker claim each attempt 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, later attempts exclude accepted addresses. - ๐ฎ 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 โฅ 14.19.3 โ same code, both runtimes. See supported runtimes.
- ๐ค Ships with AI agent skills โ see AI agent skills below.
- ๐ Hand-tuned ESM + CJS + TypeScript declarations.
- ๐งช 99%+ Jest line 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 concurrent duplicate claims. SMTP acceptance and queue completion cannot be one atomic transaction: a worker crash between them can still cause a later retry. See reliability boundary.
|===================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? See the Migrations table below for links to the detailed guides.
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 globally:
npx skills add veliovgroup/mail-time -g
# Or 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/josk -gThe 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.
Note
The skill source is not shipped in the npm tarball โ it's distributed via GitHub and consumed only by AI tooling.
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 () => {
const finished = await mailQueue.destroy({ drain: true, schedulerTimeout: 30_000 });
if (!finished) console.error('Queue scan did not finish before scheduler timeout');
});Graceful destroy waits for JoSk's running queue scan, then in-flight SMTP; claim renewal keeps running while sends drain. Sends still waiting for a concurrency slot are dropped at once and their rows stay unclaimed for the next scan, so in-flight SMTP does not count against schedulerTimeout. schedulerTimeout defaults to 10 seconds for JoSk's running-handler wait; it does not bound JoSk's own storage scan, SMTP, or policy hooks. A timed-out handler or a failed JoSk shutdown (logged) returns false; the Promise never rejects. destroy() without { drain: true } stops immediately: in-flight completions make no storage writes and their claims recover after sendingTimeout. See JoSk 6.4 recovery and shutdown.
pause() stops this server instance from competing for the queue-drain lease without tearing it down (unlike destroy()). In-flight SMTP sends finish; sends still waiting for a concurrency slot are dropped and their rows stay queued; other server instances keep draining. resume() resumes and triggers an immediate scan.
mailQueue.pause(); // stop draining on this pod
// ...SMTP provider rate-limit clears / maintenance window ends...
mailQueue.resume(); // resume; an immediate scan kicks off
mailQueue.isPaused; // boolean
(await mailQueue.ping()).paused // boolean โ observable in health checks
// Stop draining AND wait for in-flight sends to settle:
mailQueue.pause();
await mailQueue.drain();Both return boolean and are no-ops (returning false) on client instances or after destroy(). Use for SMTP rate-limit backpressure, rolling deploys, or quota windows.
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, strongest consistency. |
| 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: 2min |
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: 2min |
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. CAS blocks concurrent claims; SMTP rate limits still apply. |
sendingTimeout |
300000 (5 min) |
Stale-lock recovery window. Must exceed worst-case SMTP roundtrip; MailTime logs a warning below 120000. |
renewClaim |
sendingTimeout / 3 |
Set false to go back to a single stamp at claim time. Lower it if your storage round-trip is slow relative to sendingTimeout. |
maxRenewals |
10 |
Renewal-attempt ceiling. Recovery begins sendingTimeout after last successful stamp; with responsive storage this is roughly sendingTimeout + maxRenewals ร renewClaim. |
shouldFailOver |
โ | Set when your transport can tell "never delivered" from "may have been delivered" โ see Transport fail-over. |
strictPayload |
false |
Turn on when anything other than your own app can write to the queue storage. |
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. After an unclean stop during a claimed scan, JoSk 6.4 preserves the claim until zombieTime; use graceful destroy on redeploy. |
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.
backup rotates after failsToNext failures. Veto rotation when an MTA may already hold the message:
new MailTime({
// ...
shouldFailOver: (error) => error?.code !== 'EMESSAGE', // nodemailer: rejected after DATA
});Transports may also set error.mayFailOver = false. Retry timing stays unchanged. Edge cases: v4.1 โ v5 migration guide.
Opt in with recipientPolicies to suppress recipients before SMTP or classify attributable transport rejections. Omitting it preserves existing delivery and callback behavior. Supply your own suppression store:
const suppressed = new Set(['unsubscribed@example.com']);
new MailTime({
queue, transports, josk,
recipientPolicies: [{
name: 'unsubscribe',
beforeSend({ recipients }) {
return { decisions: recipients.filter(({ address }) => suppressed.has(address))
.map(({ address }) => ({ address, status: 'suppressed', reason: 'unsubscribed' })) };
},
}],
onSuppressed(task, recipients) {
console.log(task.uuid, recipients.map(({ address }) => address));
},
});Each to, cc, bcc, and from entry must hold one mailbox, for example "Doe, John" <user@example.com> or { name, address }; use arrays for several recipients. An address MailTime cannot parse fails the task without SMTP or further retries, with error.code === 'MAIL_TIME_INVALID_ADDRESS' and error.field naming the field. Only the SMTP envelope is filtered. Headers remain unchanged; some transports retain BCC in generated MIME. If suppressed addresses must not appear in message content, remove them from headers before enqueueing. See recipient policy hooks, outcomes, recovery, and rollout.
raw is always refused. Use strictPayload when queue writers should not control attachments, URLs, envelope, DKIM, or other nodemailer capabilities:
new MailTime({
// ...
strictPayload: true,
allowedMailFields: ['attachments'], // optional allowlist extension
});strictPayload also forces disableFileAccess and disableUrlAccess. Threat model and built-in allowlist: v4.1 โ v5 migration guide.
from receives selected transport plus { index, from }:
new MailTime({
// ...
from: (transport, details) => `"Acme" <${details.from}>`,
});Use details.from, not transport.options.from, for class-instance transports. MailTime.transportFrom(transport) exposes same resolution. Details: v4.1 โ v5 migration guide.
Two Mustache-like placeholder forms:
{{key}}โ string interpolation. HTML-escaped in HTML contexts (html,template,concatDelimiter); verbatim intextbodies and subject headers, which are not HTML.{{{key}}}โ raw interpolation, never escaped. Only use it for values you produced yourself.
Migration behavior: v4.1 โ v5 template escaping.
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 terminal rows, including settled policy outcomes. |
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. CAS blocks concurrent claims for one row. |
sendingTimeout |
number (ms) |
300000 |
Window after which a stuck isSending=true row becomes eligible again. Must exceed worst-case SMTP roundtrip; values below 120000 log a warning. |
renewClaim |
boolean | number (ms) |
sendingTimeout / 3 |
Re-stamp sendingAt on the claimed row while its SMTP roundtrip runs, so a slow-but-healthy send never loses its lock to a recovery worker. false restores the v4 single-stamp behaviour. |
maxRenewals |
number |
10 |
Renewal-attempt budget per send. Recovery begins sendingTimeout after last successful stamp; slow renewal writes can extend elapsed time beyond the nominal interval calculation. |
shouldFailOver |
(error, info, email) => boolean |
โ | Veto rotating to the next transport for this failure. Default: rotate unless the error carries mayFailOver === false. See Transport fail-over. |
strictPayload |
boolean |
false |
Narrow every queued letter to allowedMailFields and force nodemailer's disableFileAccess / disableUrlAccess. See Queue payload trust. |
allowedMailFields |
string[] |
โ | Extra field names permitted under strictPayload (added to the built-in allowlist). |
verifyTransports |
boolean |
true |
Probe each transport via transport.verify() once at ready(). Failing transports are quarantined (skipped during rotation/fallback) and surfaced through onError(error, null, { transportIndex, phase: 'verify' }). Throws from ready() if every transport fails. Transports without a verify() method are treated as healthy. MailTime always calls verify(callback) with a (error, success) => void callback. The first of the callback, a returned thenable, a synchronous throw, or a synchronous true/false return decides. A false from Nodemailer (transport without verify) means healthy, and a callback success of false is ignored. Any other synchronous return value is ignored. A quarantined transport is re-probed in the background once its backoff elapses (60 s, doubling to 15 min, no option). A successful probe returns it to rotation. Sends never wait for a probe. Probes are single-flight: a probe that times out stays in flight until its verdict arrives, so at most one verify() is outstanding per transport. No probes with verifyTransports: false, on type: 'client', or after destroy(). |
verifyTimeout |
number |
30000 |
Milliseconds ready() waits for each transport.verify(). Positive numbers, including Infinity, are clamped to 2147483647. Anything else (non-number, 0, negative, NaN) uses 30000. On timeout ready() stops waiting, logs one warning, and the transport keeps its health (usable if it was usable, quarantined if it was quarantined). No onError. A verdict that arrives later still applies: success clears a quarantine, failure quarantines the transport and calls onError once. A verify() that neither calls back nor returns a Promise delays ready() by this value and stays usable. |
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, details) => string |
โ | Strongly recommended for spam-passing From: formatting. details is { index, from } โ see Resolving the sender. |
debug |
boolean |
false |
Verbose logs. |
onSent(email, info) |
function |
โ | Without policies, called once the task is fully delivered. Policy mode adds grouped recipients, summary arguments. email.mailOptions[i].accepted lists every address that got through (across all attempts). |
onError(error, email, details) |
function |
โ | details is typed MailTimeErrorDetails: { phase?, transportIndex?, attempt? } plus the SMTP info keys of a failed attempt. Without policies, called once the retry budget is exhausted with at least one un-accepted recipient. Policy mode adds grouped recipients, summary arguments. 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' }. Also fires once when a storage write that records a send outcome throws, with details.phase of 'complete' (final or retry-release write) or 'checkpoint' (policy mode, recipient results after SMTP). |
recipientPolicies |
MailTimeRecipientPolicy[] |
โ | Optional nonempty provider list. When set, sendMail() rejects an unparseable address (MAIL_TIME_INVALID_ADDRESS, error.field such as to[1]) before enqueue, and a server constructor throws for an unparseable string from or transport from (transports[i].from). See Recipient policies. |
onSuppressed(task, recipients, summary) |
function |
โ | Terminal suppressed group in policy mode. |
onRejected(task, recipients, summary) |
function |
โ | Terminal permanently rejected group in policy mode. |
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 |
Recovery after an unfinished scan or unclean worker death. Do not drop below 60s. |
lockLeaseTime |
30000 |
JoSk scheduler lease TTL. JoSk floors it at 2 * maxRevolvingDelay + 1000; increase for slow storage claim batches. Separate from zombieTime, which controls task recovery. |
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, paused?, error?}>. Pings scheduler then queue;pausedreflectsisPaused.ready()โPromise<MailTime>. Awaits all startup work; rejects on storage failure with the original error in.cause(Errorcauseis ignored below Node 16.9).destroy(opts?)โbooleanorPromise<boolean>when{ drain: true }. Graceful shutdown waits for JoSk's scan and the SMTP pool; sends not yet started are dropped and stay queued;schedulerTimeoutdefaults to 10000 ms; a timed-out scan or failed JoSk shutdown resolvesfalse(never rejects). Plaindestroy()aborts completion writes.drain()โPromise<{ failedWrites }>. Resolves once every in-flight SMTP attempt finishes.failedWritescounts, since instance creation, storage writes that threw while recording an outcome, plus outcome writes lost after a claim-renewal error. Such a lost write is retried once with the renewal's stamp, except when the claim was already stale at renewal time; the reported message isoutcome write lost (renewal outcome uncertain or lease taken over). Each is also reported throughonErrorwithdetails.phase('complete'or'checkpoint'), except after a plaindestroy(), which still counts but does not callonError. Such a row can staysendingand be re-sent aftersendingTimeout. Read it afterdestroy({ drain: true })withawait mailQueue.drain()to learn whether shutdown was clean. Useful in tests and graceful-shutdown paths.pause()/resume()โboolean. Server-only reversible backpressure; no-ops onclientor afterdestroy(). See Shutdown.isPausedโboolean. Read-only; alwaysfalseonclient.
| Constructor | Required option | Optional |
|---|---|---|
new RedisQueue({ client, prefix?, useHashTags? }) |
connected redis@^4/^5 standalone or Cluster client |
prefix โ inherited from MailTime when omitted. Set useHashTags: true on Redis/KeyDB Cluster; set same option on JoSk Redis adapter. |
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.
Set useHashTags: true on both RedisQueue and its JoSk Redis adapter. Queue state then uses mailtime:{prefix}:letters plus a tagged sorted-set schedule, so node-redis createCluster() can route Lua operations to one slot with Redis client 4 or 5. JoSk 6.4 rejects Cluster clients without the setting. One prefix is one slot; shard high-volume traffic across prefixes. Tagged iterate returns at most 100 due rows per tick. Existing standalone keys are not read in this mode. Follow Redis Cluster migration before cutover.
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.MailTime.transportFrom(transport)โstring | undefined. Resolves a transport's sender address acrossoptions.from,transporter.options.from,_defaults.from, and_options.from, accepting both'a@b.c'and{ name, address }. Returns the first usable address;undefinedwhen none resolves. Same value thefrom(transport, details)callback receives asdetails.from.
Upgrade checklists, adapter contract changes, and rollout notes live in the docs (no duplication here):
| From | To | Full guide |
|---|---|---|
| 3.x | 4.1 | v3 โ v4 |
| 4.0 | 4.1 | v4.0 โ v4.1 |
| 4.1 | 5.0 | v4.1 โ v5 |
| 5.0 | 5.1 | v5 โ v5.1 Redis Cluster |
| 5.2 | 5.3 | v5.2 โ v5.3 |
New in v4 (opt-in): mailTimePreset, concurrency/mode, sendingTimeout, drain()/pause()/resume(), per-recipient handling, AI 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.
engines.node is >=14.19.3. The published package (ESM index.js and CJS index.cjs) is tested from a packed tarball with test/runtime-matrix/run.sh on Node 14.19.3, 14.21.3, 16.20.2, 18.19.1, 20.11.1, 22.21.1, 24.16.0 and Bun. Each run sends through a stub transport with MongoQueue, and checks load, shutdown, pause()/resume() and drain behavior (12 checks, all passing).
- Node 12 fails to parse the package (optional chaining). Node 14.0 to 14.16 lack
crypto.randomUUID; 14.17 to 14.18 are untested. - The dependency
josk@6.5.0declaresengines.node >=14.21.3. Package managers that enforceengines(npm --engine-strict, Yarn 1) reject the install on Node 14.19.3 to 14.21.2. - Store drivers set their own floor.
mongodb7 needs Node 20.19+,mongodb6 needs 16.20.1+,mongodb5 needs 14.20.1+;redis5 needs 18.19+;pg8 needs 16+. The Node 14 and 16 runs usedmongodb@3.7.4. OnlyMongoQueueis tested below Node 18.RedisQueueandPostgresQueueload on those versions but are untested there. - The repository's own test suite (Jest 30, Mocha 11, TypeScript 6) needs Node 20 or later (dev toolchain). CI runs it on Node 20.9, 22 and LTS, and runs the packed-artifact matrix on Node 14.19.3, 16.20.2 and 18.19.1.
MailTime ships pure ESM with a generated CJS bundle. Both runtimes (Bun โฅ 1.1.0, Node โฅ 14.19.3) 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.
- Try ๐ Bridge CDN - A SEO-focused Cloudflare alternative. CDN, DNS, IndexNow, Prerender, SEO, Edge Computing.
- Try โ๏ธ meteor-files.com.
- Try โฒ ostr.io for server monitoring, web analytics, web-CRON, and SEO pre-rendering.
- Star on GitHub and NPM.
- Sponsor maintainer on GitHub.
- Sponsor veliovgroup on GitHub.
- PayPal.