ostrio:mailer

v4.0.0โ€ขPublished 2 weeks ago

MailTime

npm version CI license GitHub Sponsors Donate

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.

Features

  • ๐Ÿข Horizontally scaled โ€” synchronize one queue across N processes/hosts/DCs.
  • ๐Ÿ” Multi-SMTP rotation โ€” backup (failover) and balancer (round-robin) strategies.
  • ๐Ÿ’ช Built-in retries โ€” storage-backed retry with per-letter transport pinning.
  • ๐ŸŽฏ Per-recipient retries โ€” when a multi-to send is partially rejected, only the un-accepted addresses are retried; delivered ones never see a duplicate.
  • ๐Ÿ“ฎ Email concatenation โ€” fold same-to emails 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.

How it works

Single point of failure

|----------------|         |------|         |------------------|
|  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 ------/

Multiple SMTP providers

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 |--/
                           |--------|

Sending emails from a cluster

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.

Installation

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 nodemailer

[!NOTE] 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.

AI agent skills

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/josk

The 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.

Quick start

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.

1. Import

1// ESM
2import { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } from 'mail-time';
3
4// CommonJS
5const { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } = require('mail-time');

2. Create nodemailer transports

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.

1// transports.js
2import nodemailer from 'nodemailer';
3
4export const transports = [
5  nodemailer.createTransport({
6    host: 'smtp.example.com',
7    from: 'no-reply@example.com',
8    auth: { user: 'no-reply', pass: process.env.SMTP_PASS },
9  }),
10];

3. Initialize MailTime with a preset

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.

1// mail-queue.js โ€” transactional emails on Redis
2import { MailTime, RedisQueue, mailTimePreset } from 'mail-time';
3import { createClient } from 'redis';
4import { transports } from './transports.js';
5
6const redisClient = await createClient({ url: process.env.REDIS_URL }).connect();
7
8const mailQueue = new MailTime(mailTimePreset('transactional', {
9  type: 'server',
10  prefix: 'app',
11  queue: new RedisQueue({ client: redisClient }),
12  josk: { adapter: { type: 'redis', client: redisClient } },
13  transports,
14  from: (t) => `"Awesome App" <${t.options.from}>`,
15  onSent(email, info) {
16    console.log('sent', email.uuid, info);
17  },
18  onError(error, email, info) {
19    console.error('failed', email.uuid, error, info);
20  },
21}));
22
23await mailQueue.ready();
24export { 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 }.

4. Send and cancel

1import { mailQueue } from './mail-queue.js';
2
3const uuid = await mailQueue.sendMail({
4  to: 'user@example.com',
5  subject: 'You\'ve got an email!',
6  text: 'Plain text body',
7  html: '<h1>HTML</h1><p>Styled body</p>',
8});
9
10// later โ€” cancel before sendAt:
11await mailQueue.cancelMail(uuid); // true | false

sendMail returns a stable uuid you can store for cancellation. Pass any nodemailer message option โ€” to, subject, text, html, attachments, cc, bcc, custom headers, etc.

5. Client-only mode

App servers that only enqueue need no transports, no josk โ€” just the queue. Use the same prefix as the server that drains the class.

1import { MailTime, RedisQueue } from 'mail-time';
2import { createClient } from 'redis';
3
4const redisClient = await createClient({ url: process.env.REDIS_URL }).connect();
5
6export const mailQueue = new MailTime({
7  type: 'client',
8  prefix: 'app',
9  queue: new RedisQueue({ client: redisClient }),
10});

6. Shutdown

1process.on('SIGTERM', async () => {
2  await mailQueue.destroy({ drain: true }); // stop scheduler, then wait for in-flight SMTPs
3});

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.

Storage layouts

Queue storage and scheduler storage can be the same store or different ones. Use this matrix:

QueueScheduler (josk)Best for
PostgresPostgresMulti-DC, mixed clocks, strict exactly-once.
RedisRedisHigh-throughput single-region.
MongoMongoApps already on Mongo (especially Meteor).
MongoRedisDurable letter storage + sub-second polling.
RedisMongoHot Redis letters + Mongo for scheduler.

For split-store setups pass a different client to each:

1const mailQueue = new MailTime({
2  prefix: 'app',
3  queue:  new MongoQueue({ db }),
4  transports,
5  josk: {
6    adapter: { type: 'redis', client: redisClient },
7  },
8  /* ... */
9});

Settings presets

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.

1import { MailTime, RedisQueue, mailTimePreset } from 'mail-time';
2
3const mailTime = new MailTime(mailTimePreset('otp', {
4  prefix: 'otp',
5  queue: new RedisQueue({ client: redisClient }),
6  transports: [otpTransport],
7  josk: { adapter: { type: 'redis', client: redisClient } },
8}));
PresetShapeBest for
transactionalretries: 30, retryDelay: 10s, concatEmails: false, concurrency: 1, josk.zombieTime: 120sReceipts, password resets, account changes, welcome emails.
otpretries: 5, retryDelay: 2s, snappy revolvingInterval: 1024 + jitter 256/1024, concurrency: 4, sendingTimeout: 60sSign-in codes, 2FA, verification codes โ€” stale OTPs aren't worth resending forever.
newsletterconcatEmails: true with a 5-minute fold window, concatSubject: 'Your updates', retries: 5, retryDelay: 60s, concurrency: 2, sendingTimeout: 10min, josk.zombieTime: 5minScheduled digests, weekly summaries, "what's new" emails.
marketingretries: 10, retryDelay: 30s, concatEmails: false, concurrency: 5, josk.zombieTime: 3minPromotional / campaign blasts where each letter is unique.
notificationsconcatEmails: true with a 60-second fold window, concatSubject: 'New activity', retries: 8, retryDelay: 30s, concurrency: 3, josk.zombieTime: 3minApp / social activity (likes, mentions, follows) where bursts collapse into one letter.
alertsretries: 20, retryDelay: 5s, snappy revolvingInterval: 1024 + jitter 256/1024, concurrency: 2, sendingTimeout: 60sOps / 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 prefix for every client and server that share one logical queue.
  • Different prefix per class so namespaces don't collide.
  • Never reuse prefix across two instances with different concatEmails, 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.

Dedicated mail host

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.

Tuning

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 reference (when to touch)

OptionDefaultChange when
mode'batch''one' to claim a single row per tick (fairness over throughput across cluster nodes)
concurrency1Raise to send N emails in parallel per instance. Bounded by your SMTP / API rate limits. The CAS on isSending keeps it safe.
sendingTimeout300000 (5 min)Stale-lock recovery window. Must exceed worst-case SMTP roundtrip; lower it only when you're confident sends never take that long.
revolvingInterval1536 msLower โ†’ faster pickup; higher โ†’ less scheduler I/O
josk.minRevolvingDelay / maxRevolvingDelay512 / 2048Lower both โ†’ snappier polls, more storage load
josk.zombieTime60000Never below 60s. Iterate releases the JoSk lease as soon as scanning ends โ€” only a stalled storage scan can blow this.
josk.concurrencyInfinitySet 1 if scheduler ticks overlap while iterate still runs
josk.execute'batch'Usually leave default; MailTime only registers one JoSk task per instance
josk.lockOwnerIdrandomSet in production for observability
retries / retryDelay59 / 60sretries is after the first attempt; default 59 means 60 total attempts. Per email class โ€” transactional shorter, marketing longer.
concatEmails / concatDelayfalse / 60sOn 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.

Templates

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:

1const layouts = {
2  envelope: `<html><body>{{{html}}}<footer>Sent to @{{username}} ({{to}})</footer></body></html>`,
3  otp: {
4    text: 'Hello @{{username}}! Your code: {{code}}',
5    html: '<h1>Sign-in</h1><p>Hello <b>@{{username}}</b></p><pre><code>{{code}}</code></pre>',
6  },
7};
8
9const mailQueue = new MailTime({
10  /* ... */
11  template: layouts.envelope,
12});
13
14await mailQueue.sendMail({
15  to: 'user@example.com',
16  subject: 'Sign-in code',
17  username: 'mike',
18  code: 'A1B2-C3D4',
19  text: layouts.otp.text,
20  html: layouts.otp.html,
21});

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.

API

new MailTime(opts)

OptionTypeDefaultNotes
queueMongoQueue | 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.
transportsnodemailer.Transport[]โ€”Required for server. Non-empty.
joskMailTimeJoSkOptionsโ€”Required for server. See JoSk options below.
strategy'backup' | 'balancer''backup'Multi-SMTP rotation policy.
failsToNextnumber4(backup) failures-in-a-row before rotating.
retriesnumber59Re-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.
retryDelaynumber (ms)60000Wait between attempts.
keepHistorybooleanfalseKeep sent/failed/cancelled rows.
concatEmailsboolean | { subject?: string }falseFold same-to letters into one. Pass { subject: 'X' } to set the folded-letter subject inline; the string supports the {{count}} placeholder and overrides concatSubject.
concatSubjectstring'Multiple notifications'Subject when folded. Supports {{count}} for the folded letter count.
concatDelimiterstring'<hr>'Separator between folded bodies.
concatDelaynumber (ms)60000Fold window.
revolvingIntervalnumber (ms)1536Queue iteration interval.
mode'one' | 'batch''batch''batch' claims every due row per tick; 'one' claims a single row per tick.
concurrencynumber1Parallel SMTPs per instance. The CAS on isSending prevents duplicate delivery.
sendingTimeoutnumber (ms)300000Window after which a stuck isSending=true row becomes eligible again. Must exceed worst-case SMTP roundtrip.
verifyTransportsbooleantrueProbe 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.
templatestring'{{{html}}}'Default envelope.
prefixstring''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>.
fromstring | (transport) => stringโ€”Strongly recommended for spam-passing From: formatting.
debugbooleanfalseVerbose 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' }.

JoSk options

opts.josk is passed to the underlying JoSk constructor. Useful keys:

KeyDefaultNotes
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.
minRevolvingDelay512Lower bound of poll window.
maxRevolvingDelay2048Upper bound.
zombieTime60000Re-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).
concurrencyInfinityCap overlapping JoSk handler runs on this process (1 if ticks pile up).
autoClearfalseRemove orphan tasks from storage.
lockOwnerIdjosk-<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).

Methods

  • 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.
  • send(opts) โ€” alias of sendMail.
  • cancelMail(uuidOrPromise) โ†’ Promise<boolean>. Accepts the uuid or the Promise<string> from sendMail.
  • cancel(uuid) โ€” alias of cancelMail.
  • ping() โ†’ Promise<{status, code, statusCode, error?}>. Pings scheduler then queue.
  • ready() โ†’ Promise<MailTime>. Awaits all startup work; rejects with .cause on storage failure.
  • destroy(opts?) โ†’ boolean or Promise<boolean> when { drain: true }. Stops scheduler. Idempotent. Use destroy({ drain: true }) or await drain() after destroy() for graceful shutdown.
  • drain() โ†’ Promise<void>. Resolves once every in-flight SMTP attempt finishes. Useful in tests and graceful-shutdown paths.

Queue constructors

ConstructorRequired optionOptional
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.Clientprefix โ€” inherited from MailTime when omitted. mail_time_queue table auto-migrated on first ready().

For custom adapters see docs/queue-api.md.

Module functions

  • mailTimePreset(name, overrides?) โ†’ fresh MailTime constructor config. Deep-clones the named preset and deep-merges your overrides (scalars win, nested josk composes). Throws on unknown name or non-object overrides.
  • presets โ€” read-only { [name]: partialConfig } map backing mailTimePreset.
  • presetNames โ€” read-only array of preset names.

Static

  • MailTime.Template โ€” get/set the default HTML envelope template.

Migration from 3.x

Full v4 highlights, adapter changes, and type exports live in docs/migration-v3-v4.md. Quick checklist:

  1. Node โ‰ฅ 20.9.0, Bun โ‰ฅ 1.1.0. Bump your runtime first.
  2. Swap adapter imports to the new MongoQueue / RedisQueue / PostgresQueue constructors.
  3. Pass josk โ€” it's now required for type: 'server'.
  4. josk.zombieTime default raised to 60000 ms (was 32786). Set it explicitly if you relied on the old value.
  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 and adapters/blank-example.js.
  6. Default behavior unchanged โ€” concurrency: 1 keeps the post-upgrade send rate identical to v3. Opt into parallel sends by raising concurrency.

New v4 surface to opt into: mailTimePreset, concurrency, mode, sendingTimeout, drain(), per-recipient retries, and the AI agent skills bundle.

Testing

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/jest

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.

Bun

MailTime ships pure ESM with a generated CJS bundle. Both runtimes (Bun โ‰ฅ 1.1.0, Node โ‰ฅ 20.9.0) load it directly:

1import { MailTime } from 'mail-time'; // works in both

Mixed clusters (some Node, some Bun) share one schedule under the same prefix โ€” the lease lives in storage, runtime-agnostic.

License

BSD-3-Clause.

Support this project