mail-time 4.0.0 → 5.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,17 +1,25 @@
1
1
 
2
2
  # MailTime
3
3
 
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)
4
+ [![npm version][badge-npm-v]][npm-url]
5
+ [![npm downloads][badge-npm-dm]][npm-url]
6
+ [![CI][badge-ci]][ci-url]
7
+ [![bundle size][badge-size]][size-url]
8
+ [![Coverage][badge-cov]](#testing)
9
+ [![License: BSD-3-Clause][badge-license]][license-url]
10
+ [![Node.js][badge-node]][node-url]
11
+ [![TypeScript][badge-ts]][ts-url]
12
+ [![Bun][badge-bun]][bun-url]
13
+ [![Meteor][badge-meteor]][meteor-url]
14
+ [![dependencies][badge-deps]][deps-url]
15
+ [![Sponsor][badge-sponsor]][sponsor-url]
16
+ [![Donate][badge-donate]][donate-url]
9
17
 
10
18
  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
19
 
12
20
  `MailTime` runs in one of two modes:
13
21
 
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.
22
+ - **`server`** — drains the queue and sends emails via SMTP. Per-row leases let one worker claim each attempt even when many are running.
15
23
  - **`client`** — only enqueues emails. Use for app servers in a "dedicated mail micro-service" topology.
16
24
 
17
25
  Many clients + one or more servers coexist behind the same `prefix` in the same store.
@@ -21,14 +29,14 @@ Many clients + one or more servers coexist behind the same `prefix` in the same
21
29
  - 🏢 **Horizontally scaled** — synchronize one queue across N processes/hosts/DCs.
22
30
  - 🔁 **Multi-SMTP rotation** — `backup` (failover) and `balancer` (round-robin) strategies.
23
31
  - 💪 **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.
32
+ - 🎯 **Per-recipient retries** — when a multi-`to` send is partially rejected, later attempts exclude accepted addresses.
25
33
  - 📮 **Email concatenation** — fold same-`to` emails arriving inside a window into one letter.
26
34
  - 🎛️ **One-line setup** — built-in [presets](#settings-presets) for `transactional`, `otp`, `newsletter`, `marketing`, `notifications`, `alerts`.
27
35
  - 🛢️ **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
36
  - 📦 **Bun ≥ 1.1.0 & Node ≥ 20.9.0** — same code, both runtimes.
29
37
  - 🤖 **Ships with AI agent skills** — see [AI agent skills](#ai-agent-skills) below.
30
38
  - 📐 **Hand-tuned ESM + CJS + TypeScript declarations**.
31
- - 🧪 **95%+ Jest coverage** (85% threshold enforced) + Mocha integration tests for every adapter.
39
+ - 🧪 **99%+ Jest line coverage** (85% threshold enforced) + Mocha integration tests for every adapter.
32
40
 
33
41
  ## How it works
34
42
 
@@ -76,7 +84,7 @@ MailTime keeps every letter in the queue until SMTP confirms delivery.
76
84
 
77
85
  ### Sending emails from a cluster
78
86
 
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.
87
+ 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](https://github.com/veliovgroup/mail-time/blob/master/docs/tuning.md#reliability-boundary).
80
88
 
81
89
  ```ascii
82
90
  |===================THE=CLUSTER===================| |=QUEUE=|
@@ -131,7 +139,7 @@ bun add mail-time nodemailer
131
139
  > `nodemailer` and adapter drivers are peers (not bundled) so you can pin your own versions.
132
140
 
133
141
  > [!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).
142
+ > Upgrading? See the [Migrations](#migrations) table below for links to the detailed guides.
135
143
 
136
144
  For Meteor.js usage see [docs/meteor.md](https://github.com/veliovgroup/mail-time/blob/master/docs/meteor.md).
137
145
 
@@ -140,16 +148,22 @@ For Meteor.js usage see [docs/meteor.md](https://github.com/veliovgroup/mail-tim
140
148
  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.
141
149
 
142
150
  ```sh
143
- # Install the MailTime skill into the current project:
151
+ # Install the MailTime skill globally:
152
+ npx skills add veliovgroup/mail-time -g
153
+
154
+ # Or install the MailTime skill into the current project:
144
155
  npx skills add veliovgroup/mail-time
145
156
 
146
157
  # Recommended: also install the JoSk skill — MailTime is built on JoSk,
147
158
  # and deep scheduler questions resolve through JoSk's contract.
148
- npx skills add veliovgroup/josk
159
+ npx skills add veliovgroup/josk -g
149
160
  ```
150
161
 
151
162
  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.
152
163
 
164
+ > [!NOTE]
165
+ > The skill source is **not** shipped in the npm tarball — it's distributed via GitHub and consumed only by AI tooling.
166
+
153
167
  ## Quick start
154
168
 
155
169
  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.
@@ -257,9 +271,26 @@ process.on('SIGTERM', async () => {
257
271
  });
258
272
  ```
259
273
 
260
- Or explicitly: `mailQueue.destroy(); await mailQueue.drain();`
274
+ `destroy()` without `{ drain: true }` stops immediately: in-flight completions make no storage writes and their claims recover after `sendingTimeout`. Use it for forced teardown, not graceful shutdown.
275
+
276
+ #### Pause / resume a server (backpressure)
277
+
278
+ `pause()` stops this **server** instance from competing for the queue-drain lease without tearing it down (unlike `destroy()`). In-flight SMTP sends finish; other `server` instances keep draining. `resume()` resumes and triggers an immediate scan.
261
279
 
262
- `destroy()` is idempotent and stops the scheduler. Always call it from tests; pair with `drain()` when iterate-driven sends ran.
280
+ ```js
281
+ mailQueue.pause(); // stop draining on this pod
282
+ // ...SMTP provider rate-limit clears / maintenance window ends...
283
+ mailQueue.resume(); // resume; an immediate scan kicks off
284
+
285
+ mailQueue.isPaused; // boolean
286
+ (await mailQueue.ping()).paused // boolean — observable in health checks
287
+
288
+ // Stop draining AND wait for in-flight sends to settle:
289
+ mailQueue.pause();
290
+ await mailQueue.drain();
291
+ ```
292
+
293
+ 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.
263
294
 
264
295
  ## Storage layouts
265
296
 
@@ -267,7 +298,7 @@ Queue storage and scheduler storage can be the same store or different ones. Use
267
298
 
268
299
  | Queue | Scheduler (`josk`) | Best for |
269
300
  | -------- | ------------------ | -------------------------------------------- |
270
- | Postgres | Postgres | Multi-DC, mixed clocks, strict exactly-once. |
301
+ | Postgres | Postgres | Multi-DC, mixed clocks, strongest consistency. |
271
302
  | Redis | Redis | High-throughput single-region. |
272
303
  | Mongo | Mongo | Apps already on Mongo (especially Meteor). |
273
304
  | Mongo | Redis | Durable letter storage + sub-second polling. |
@@ -305,11 +336,11 @@ const mailTime = new MailTime(mailTimePreset('otp', {
305
336
  | Preset | Shape | Best for |
306
337
  | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
307
338
  | `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. |
339
+ | `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. |
309
340
  | `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
341
  | `marketing` | `retries: 10`, `retryDelay: 30s`, `concatEmails: false`, `concurrency: 5`, `josk.zombieTime: 3min` | Promotional / campaign blasts where each letter is unique. |
311
342
  | `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. |
343
+ | `alerts` | `retries: 20`, `retryDelay: 5s`, snappy `revolvingInterval: 1024` + jitter `256/1024`, `concurrency: 2`, `sendingTimeout: 2min` | Ops / admin alerts: monitoring, error reports, escalations. |
313
344
 
314
345
  Presets are equally useful on `type: 'client'` instances — keys that don't apply to the client role are simply ignored.
315
346
 
@@ -338,8 +369,12 @@ Defaults fit moderate traffic in a single region. Reach for a [preset](#settings
338
369
  | Option | Default | Change when |
339
370
  | ---------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
340
371
  | `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. |
372
+ | `concurrency` | `1` | Raise to send N emails in parallel per instance. CAS blocks concurrent claims; SMTP rate limits still apply. |
373
+ | `sendingTimeout` | `300000` (5 min) | Stale-lock recovery window. Must exceed worst-case SMTP roundtrip; MailTime logs a warning below `120000`. |
374
+ | `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`. |
375
+ | `maxRenewals` | `10` | Renewal-attempt ceiling. Recovery begins `sendingTimeout` after last successful stamp; with responsive storage this is roughly `sendingTimeout + maxRenewals × renewClaim`. |
376
+ | `shouldFailOver` | — | Set when your transport can tell "never delivered" from "may have been delivered" — see [Transport fail-over](#transport-fail-over). |
377
+ | `strictPayload` | `false` | Turn on when anything other than your own app can write to the queue storage. |
343
378
  | `revolvingInterval` | `1536` ms | Lower → faster pickup; higher → less scheduler I/O |
344
379
  | `josk.minRevolvingDelay` / `maxRevolvingDelay` | `512` / `2048` | Lower both → snappier polls, more storage load |
345
380
  | `josk.zombieTime` | `60000` | **Never below 60s.** Iterate releases the JoSk lease as soon as scanning ends — only a stalled storage scan can blow this. |
@@ -352,12 +387,54 @@ Defaults fit moderate traffic in a single region. Reach for a [preset](#settings
352
387
 
353
388
  For deeper JoSk semantics (lease lifecycle, scheduler adapters, recurring tasks), install the JoSk skill: **`npx skills add veliovgroup/josk`**.
354
389
 
390
+ ## Transport fail-over
391
+
392
+ `backup` rotates after `failsToNext` failures. Veto rotation when an MTA may already hold the message:
393
+
394
+ ```js
395
+ new MailTime({
396
+ // ...
397
+ shouldFailOver: (error) => error?.code !== 'EMESSAGE', // nodemailer: rejected after DATA
398
+ });
399
+ ```
400
+
401
+ Transports may also set `error.mayFailOver = false`. Retry timing stays unchanged. Edge cases: [v4.1 → v5 migration guide](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4.1-v5.md#shouldfailovererror-info-email).
402
+
403
+ ## Queue payload trust
404
+
405
+ `raw` is always refused. Use `strictPayload` when queue writers should not control attachments, URLs, envelope, DKIM, or other nodemailer capabilities:
406
+
407
+ ```js
408
+ new MailTime({
409
+ // ...
410
+ strictPayload: true,
411
+ allowedMailFields: ['attachments'], // optional allowlist extension
412
+ });
413
+ ```
414
+
415
+ `strictPayload` also forces `disableFileAccess` and `disableUrlAccess`. Threat model and built-in allowlist: [v4.1 → v5 migration guide](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4.1-v5.md#strictpayload--allowedmailfields).
416
+
417
+ ## Resolving the sender
418
+
419
+ `from` receives selected transport plus `{ index, from }`:
420
+
421
+ ```js
422
+ new MailTime({
423
+ // ...
424
+ from: (transport, details) => `"Acme" <${details.from}>`,
425
+ });
426
+ ```
427
+
428
+ Use `details.from`, not `transport.options.from`, for class-instance transports. `MailTime.transportFrom(transport)` exposes same resolution. Details: [v4.1 → v5 migration guide](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4.1-v5.md#fromtransport-details-and-mailtimetransportfrom).
429
+
355
430
  ## Templates
356
431
 
357
432
  Two Mustache-like placeholder forms:
358
433
 
359
- - `{{key}}` — string interpolation, strips HTML from the value (safe for plain text).
360
- - `{{{key}}}` — raw HTML interpolation.
434
+ - `{{key}}` — string interpolation. **HTML-escaped** in HTML contexts (`html`, `template`, `concatDelimiter`); verbatim in `text` bodies and subject headers, which are not HTML.
435
+ - `{{{key}}}` — raw interpolation, never escaped. Only use it for values you produced yourself.
436
+
437
+ Migration behavior: [v4.1 → v5 template escaping](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4.1-v5.md#2-key-escapes-html-instead-of-stripping-tags).
361
438
 
362
439
  Every `sendMail` option is available inside `text`, `html`, and the wrapping template:
363
440
 
@@ -408,12 +485,17 @@ await mailQueue.sendMail({
408
485
  | `concatDelay` | `number` (ms) | `60000` | Fold window. |
409
486
  | `revolvingInterval` | `number` (ms) | `1536` | Queue iteration interval. |
410
487
  | `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. |
488
+ | `concurrency` | `number` | `1` | Parallel SMTPs per instance. CAS blocks concurrent claims for one row. |
489
+ | `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. |
490
+ | `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. |
491
+ | `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. |
492
+ | `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](#transport-fail-over). |
493
+ | `strictPayload` | `boolean` | `false` | Narrow every queued letter to `allowedMailFields` and force nodemailer's `disableFileAccess` / `disableUrlAccess`. See [Queue payload trust](#queue-payload-trust). |
494
+ | `allowedMailFields` | `string[]` | — | Extra field names permitted under `strictPayload` (added to the built-in allowlist). |
413
495
  | `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
496
  | `template` | `string` | `'{{{html}}}'` | Default envelope. |
415
497
  | `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. |
498
+ | `from` | `string \| (transport, details) => string` | — | Strongly recommended for spam-passing `From:` formatting. `details` is `{ index, from }` — see [Resolving the sender](#resolving-the-sender). |
417
499
  | `debug` | `boolean` | `false` | Verbose logs. |
418
500
  | `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
501
  | `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' }`. |
@@ -428,6 +510,7 @@ await mailQueue.sendMail({
428
510
  | `minRevolvingDelay` | `512` | Lower bound of poll window. |
429
511
  | `maxRevolvingDelay` | `2048` | Upper bound. |
430
512
  | `zombieTime` | `60000` | Re-claim if `queue.iterate()` runs longer than this. **Do not drop below 60s.** |
513
+ | `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. |
431
514
  | `execute` | `'batch'` | JoSk scheduler batching; low impact for MailTime (one interval task per instance). |
432
515
  | `concurrency` | `Infinity` | Cap overlapping JoSk handler runs on **this** process (`1` if ticks pile up). |
433
516
  | `autoClear` | `false` | Remove orphan tasks from storage. |
@@ -443,10 +526,12 @@ For deeper JoSk semantics, install the JoSk skill: **`npx skills add veliovgroup
443
526
  - `send(opts)` — alias of `sendMail`.
444
527
  - `cancelMail(uuidOrPromise)` → `Promise<boolean>`. Accepts the `uuid` or the `Promise<string>` from `sendMail`.
445
528
  - `cancel(uuid)` — alias of `cancelMail`.
446
- - `ping()` → `Promise<{status, code, statusCode, error?}>`. Pings scheduler then queue.
529
+ - `ping()` → `Promise<{status, code, statusCode, paused?, error?}>`. Pings scheduler then queue; `paused` reflects `isPaused`.
447
530
  - `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.
531
+ - `destroy(opts?)` → `boolean` or `Promise<boolean>` when `{ drain: true }`. Stops scheduler. Idempotent. Use `await destroy({ drain: true })` for graceful shutdown; plain `destroy()` aborts completion writes.
449
532
  - `drain()` → `Promise<void>`. Resolves once every in-flight SMTP attempt finishes. Useful in tests and graceful-shutdown paths.
533
+ - `pause()` / `resume()` → `boolean`. Server-only reversible backpressure; no-ops on `client` or after `destroy()`. See [Shutdown](#6-shutdown).
534
+ - `isPaused` → `boolean`. Read-only; always `false` on `client`.
450
535
 
451
536
  ### Queue constructors
452
537
 
@@ -467,19 +552,19 @@ For custom adapters see [docs/queue-api.md](https://github.com/veliovgroup/mail-
467
552
  ### Static
468
553
 
469
554
  - `MailTime.Template` — get/set the default HTML envelope template.
555
+ - `MailTime.transportFrom(transport)` → `string | undefined`. Resolves a transport's sender address across `options.from`, `transporter.options.from`, `_defaults.from`, and `_options.from`, accepting both `'a@b.c'` and `{ name, address }`. Returns the first *usable* address; `undefined` when none resolves. Same value the `from(transport, details)` callback receives as `details.from`.
470
556
 
471
- ## Migration from 3.x
557
+ ## Migrations
472
558
 
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:
559
+ Upgrade checklists, adapter contract changes, and rollout notes live in the docs (no duplication here):
474
560
 
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`.
561
+ | From | To | Full guide |
562
+ |------|-----|------------|
563
+ | 3.x | 4.1 | [v3 → v4](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v3-v4.md) |
564
+ | 4.0 | 4.1 | [v4.0 → v4.1](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4-v4.1.md) |
565
+ | 4.1 | 5.0 | [v4.1 → v5](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4.1-v5.md) |
481
566
 
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.
567
+ New in v4 (opt-in): `mailTimePreset`, `concurrency`/`mode`, `sendingTimeout`, `drain()`/`pause()`/`resume()`, per-recipient handling, AI skills bundle.
483
568
 
484
569
  ## Testing
485
570
 
@@ -524,3 +609,28 @@ Mixed clusters (some Node, some Bun) share one schedule under the same `prefix`
524
609
  - [PayPal](https://paypal.me/veliovgroup).
525
610
  - Try [☄️ meteor-files.com](https://meteor-files.com/?ref=github-mail-time-repo-footer).
526
611
  - Try [▲ ostr.io](https://ostr.io?ref=github-mail-time-repo-footer) for server monitoring, web analytics, web-CRON, and SEO pre-rendering.
612
+
613
+ [npm-url]: https://www.npmjs.com/package/mail-time
614
+ [badge-npm-v]: https://img.shields.io/npm/v/mail-time.svg
615
+ [badge-npm-dm]: https://img.shields.io/npm/dm/mail-time.svg
616
+ [badge-ci]: https://github.com/veliovgroup/mail-time/actions/workflows/ci.yml/badge.svg?branch=master
617
+ [ci-url]: https://github.com/veliovgroup/mail-time/actions/workflows/ci.yml
618
+ [badge-size]: https://img.shields.io/bundlephobia/minzip/mail-time
619
+ [size-url]: https://bundlephobia.com/package/mail-time
620
+ [badge-cov]: https://img.shields.io/badge/coverage-~99%25-brightgreen
621
+ [badge-license]: https://img.shields.io/badge/License-BSD%203--Clause-blue.svg
622
+ [license-url]: LICENSE
623
+ [badge-node]: https://img.shields.io/node/v/mail-time
624
+ [node-url]: https://nodejs.org/
625
+ [badge-ts]: https://img.shields.io/badge/TypeScript-ready-blue
626
+ [ts-url]: #quick-start
627
+ [badge-bun]: https://img.shields.io/badge/Bun-%3E%3D1.1.0-black?logo=bun
628
+ [bun-url]: #bun
629
+ [badge-meteor]: https://img.shields.io/badge/Meteor-ostrio%3Amailer-DE4F4F
630
+ [meteor-url]: https://packosphere.com/ostrio/mailer
631
+ [badge-deps]: https://img.shields.io/badge/dependencies-1-brightgreen
632
+ [deps-url]: https://www.npmjs.com/package/mail-time?activeTab=dependencies
633
+ [badge-sponsor]: https://img.shields.io/github/sponsors/dr-dimitru?label=Sponsor
634
+ [sponsor-url]: https://github.com/sponsors/dr-dimitru
635
+ [badge-donate]: https://img.shields.io/badge/Donate-PayPal-00457C?logo=paypal&logoColor=white
636
+ [donate-url]: https://paypal.me/veliovgroup
package/adapters/mongo.js CHANGED
@@ -228,10 +228,13 @@ class MongoQueue {
228
228
  cursor.limit(limit);
229
229
  }
230
230
 
231
- while (await cursor.hasNext()) {
232
- await this.mailTimeInstance.___dispatch(await cursor.next());
231
+ try {
232
+ while (await cursor.hasNext()) {
233
+ await this.mailTimeInstance.___dispatch(await cursor.next());
234
+ }
235
+ } finally {
236
+ await cursor.close().catch(() => {});
233
237
  }
234
- await cursor.close();
235
238
  } catch (iterateError) {
236
239
  logError('[iterate] [while/await] [iterateError]', iterateError);
237
240
  }
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'crypto';
1
2
  import {
2
3
  debug,
3
4
  logError,
@@ -26,7 +27,21 @@ import {
26
27
  */
27
28
 
28
29
  const DEFAULT_PREFIX = 'default';
29
- const setupLockId = 93824519;
30
+
31
+ // Two-key advisory lock, mirroring josk's PostgresAdapter: a stable MailTime namespace
32
+ // plus a per-prefix hash. `pg_advisory_lock(int4, int4)` lives in its own keyspace, isolated
33
+ // from single-int callers in the same database; distinct prefixes get distinct lock IDs, so
34
+ // co-tenant MailTime queues no longer serialize each other's schema setup.
35
+ const ADVISORY_LOCK_NAMESPACE = 0x4D61696C; // 'Mail' in ASCII as int32
36
+
37
+ /**
38
+ * @internal
39
+ * @param {string} prefix
40
+ * @returns {number} signed int32 hash of the prefix string
41
+ */
42
+ const advisoryLockKeyFor = (prefix) => {
43
+ return createHash('sha256').update(prefix).digest().readInt32BE(0);
44
+ };
30
45
 
31
46
  const fieldMap = {
32
47
  to: 'to_address',
@@ -129,7 +144,8 @@ class PostgresQueue {
129
144
 
130
145
  /** @internal */
131
146
  async __setup() {
132
- await this.client.query('SELECT pg_advisory_lock($1)', [setupLockId]);
147
+ const advisoryLockKey = advisoryLockKeyFor(this.prefix);
148
+ await this.client.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
133
149
 
134
150
  try {
135
151
  await this.client.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
@@ -160,7 +176,7 @@ class PostgresQueue {
160
176
  await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_pending_to
161
177
  ON mail_time_queue (prefix, to_address, is_sent, is_failed, is_cancelled, send_at)`);
162
178
  } finally {
163
- await this.client.query('SELECT pg_advisory_unlock($1)', [setupLockId]);
179
+ await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
164
180
  }
165
181
  }
166
182
 
package/helpers.js CHANGED
@@ -1,15 +1,43 @@
1
1
  const hasOwn = Object.prototype.hasOwnProperty;
2
2
 
3
+ /**
4
+ * @name hasOwnProp - `Object.hasOwn` (ES2022) polyfill.
5
+ * @function
6
+ * @param {object} obj
7
+ * @param {PropertyKey} key
8
+ * @returns {boolean} `true` if the object has the property, `false` otherwise.
9
+ */
10
+ const hasOwnProp = (obj, key) => hasOwn.call(obj, key);
11
+
12
+ /**
13
+ * @name debug - Debug logging.
14
+ * @function
15
+ * @param {boolean} isDebug
16
+ * @param {...any} args
17
+ * @returns {void}
18
+ */
3
19
  const debug = (isDebug, ...args) => {
4
20
  if (isDebug) {
5
21
  console.info('[DEBUG] [mail-time]', `${new Date()}`, ...args);
6
22
  }
7
23
  };
8
24
 
25
+ /**
26
+ * @name logError - Error logging.
27
+ * @function
28
+ * @param {...any} args
29
+ * @returns {void}
30
+ */
9
31
  const logError = (...args) => {
10
32
  console.error('[ERROR] [mail-time]', `${new Date()}`, ...args);
11
33
  };
12
34
 
35
+ /**
36
+ * @name isPlainObject - Check whether a value is a plain object (literal or `Object.create(null)`).
37
+ * @function
38
+ * @param {any} value
39
+ * @returns {boolean} `true` for plain objects, `false` for `null`, arrays, class instances, and primitives.
40
+ */
13
41
  const isPlainObject = (value) => {
14
42
  if (value === null || typeof value !== 'object') {
15
43
  return false;
@@ -19,11 +47,15 @@ const isPlainObject = (value) => {
19
47
  };
20
48
 
21
49
  /**
22
- * Minimal deep-merge sufficient for nodemailer-shaped mail options:
50
+ * @name deepMerge - Minimal deep-merge sufficient for nodemailer-shaped mail options:
23
51
  * - plain objects merge key-by-key
24
52
  * - arrays concatenate
25
53
  * - other values (strings, numbers, Date, Buffer, streams, classes) replace
26
54
  * Source values override target values.
55
+ * @function
56
+ * @param {any} target - Base value; merged into a shallow clone when a plain object, otherwise ignored.
57
+ * @param {any} source - Overriding value; returns `target` unchanged when not a plain object.
58
+ * @returns {any} The merged result (a new object), or `target` when `source` is not a plain object.
27
59
  */
28
60
  const deepMerge = (target, source) => {
29
61
  if (!isPlainObject(source)) {
@@ -49,9 +81,13 @@ const deepMerge = (target, source) => {
49
81
  };
50
82
 
51
83
  /**
52
- * Order-insensitive deep equality. Treats arrays as multisets and
84
+ * @name equals - Order-insensitive deep equality. Treats arrays as multisets and
53
85
  * objects as unordered maps. Designed for the small `mailOptions`
54
86
  * shape used by MailTime's email concatenation dedup.
87
+ * @function
88
+ * @param {any} a
89
+ * @param {any} b
90
+ * @returns {boolean} `true` when `a` and `b` are deeply equal ignoring array/key order.
55
91
  */
56
92
  const equals = (a, b) => {
57
93
  if (a === b) {
@@ -110,9 +146,11 @@ const equals = (a, b) => {
110
146
  };
111
147
 
112
148
  /**
113
- * Extract the email part of a nodemailer-shaped recipient entry.
149
+ * @name extractEmail - Extract the email part of a nodemailer-shaped recipient entry.
114
150
  * Accepts `'a@x.com'`, `'Name <a@x.com>'`, or `{ name, address }`.
115
- * Returns the address lowercased, or `null` when none can be parsed.
151
+ * @function
152
+ * @param {string|{name?: string, address?: string}|null|undefined} entry
153
+ * @returns {string|null} The address lowercased and trimmed, or `null` when none can be parsed.
116
154
  */
117
155
  const extractEmail = (entry) => {
118
156
  if (!entry) {
@@ -129,7 +167,10 @@ const extractEmail = (entry) => {
129
167
  };
130
168
 
131
169
  /**
132
- * Normalize a `to`/`cc`/`bcc` field into a flat list of lowercase addresses.
170
+ * @name toAddressList - Normalize a `to`/`cc`/`bcc` field into a flat list of lowercase addresses.
171
+ * @function
172
+ * @param {string|Array<string|{name?: string, address?: string}>|null|undefined} field
173
+ * @returns {string[]} Flat list of parsed lowercase addresses; empty when `field` is falsy or unparseable.
133
174
  */
134
175
  const toAddressList = (field) => {
135
176
  if (!field) {
@@ -150,9 +191,13 @@ const toAddressList = (field) => {
150
191
  };
151
192
 
152
193
  /**
153
- * Remove entries whose extracted address is in `acceptedSet` from a
154
- * nodemailer `to`/`cc`/`bcc` field. Returns `void 0` when the filtered
155
- * array would be empty or the single string is dropped.
194
+ * @name filterAddressField - Remove entries whose extracted address is in `acceptedSet` from a
195
+ * nodemailer `to`/`cc`/`bcc` field.
196
+ * @function
197
+ * @param {string|Array<string|{name?: string, address?: string}>|null|undefined} field
198
+ * @param {Set<string>} acceptedSet - Lowercase addresses to drop.
199
+ * @returns {string|Array|undefined} The filtered field, or `void 0` when the filtered array would be
200
+ * empty or the single string is dropped. Returns `field` unchanged when `acceptedSet` is empty.
156
201
  */
157
202
  const filterAddressField = (field, acceptedSet) => {
158
203
  if (!field || !(acceptedSet instanceof Set) || acceptedSet.size === 0) {
@@ -175,19 +220,61 @@ const filterAddressField = (field, acceptedSet) => {
175
220
  return field;
176
221
  };
177
222
 
223
+ const HTML_ESCAPES = {
224
+ '&': '&amp;',
225
+ '<': '&lt;',
226
+ '>': '&gt;',
227
+ '"': '&quot;',
228
+ "'": '&#39;',
229
+ };
230
+
231
+ /**
232
+ * @name escapeHtml - Escape the five HTML-significant characters.
233
+ * @function
234
+ * @param {string} value
235
+ * @returns {string} `value` safe to interpolate into HTML text or an attribute value.
236
+ */
237
+ const escapeHtml = (value) => {
238
+ return `${value}`.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
239
+ };
240
+
241
+ /**
242
+ * @name isSendClaimUpdate - Detect an atomic send-claim update (`{ isSending: true, tries: N }`).
243
+ * @function
244
+ * @param {object} updateObj
245
+ * @returns {boolean} `true` when `updateObj` claims a row for sending.
246
+ */
178
247
  const isSendClaimUpdate = (updateObj) => {
179
248
  return updateObj && updateObj.isSending === true && typeof updateObj.tries === 'number';
180
249
  };
181
250
 
251
+ /**
252
+ * @name isSendLeaseGuardedUpdate - Detect an update guarded by a send lease
253
+ * (carries `leaseTries` and `leaseSendingAt`).
254
+ * @function
255
+ * @param {object} updateObj
256
+ * @returns {boolean} `true` when both lease guard fields are present numbers.
257
+ */
182
258
  const isSendLeaseGuardedUpdate = (updateObj) => {
183
259
  return updateObj && typeof updateObj.leaseTries === 'number' && typeof updateObj.leaseSendingAt === 'number';
184
260
  };
185
261
 
262
+ /**
263
+ * @name isAppendMailOptionUpdate - Detect an update that appends a mail option (for email concatenation).
264
+ * @function
265
+ * @param {object} updateObj
266
+ * @returns {boolean} `true` when `updateObj.appendMailOption` is set.
267
+ */
186
268
  const isAppendMailOptionUpdate = (updateObj) => {
187
269
  return updateObj && updateObj.appendMailOption !== void 0;
188
270
  };
189
271
 
190
- /** Strip MailTime-internal update keys before persisting to storage. */
272
+ /**
273
+ * @name stripInternalUpdateMeta - Strip MailTime-internal update keys before persisting to storage.
274
+ * @function
275
+ * @param {object} updateObj
276
+ * @returns {object} A shallow clone without `leaseTries`, `leaseSendingAt`, and `appendMailOption`.
277
+ */
191
278
  const stripInternalUpdateMeta = (updateObj) => {
192
279
  const out = { ...updateObj };
193
280
  delete out.leaseTries;
@@ -196,13 +283,22 @@ const stripInternalUpdateMeta = (updateObj) => {
196
283
  return out;
197
284
  };
198
285
 
286
+ /**
287
+ * @name isSendLeaseRemove - Detect a remove guarded by a send lease
288
+ * (carries `leaseTries` and `leaseSendingAt`).
289
+ * @function
290
+ * @param {object} opts
291
+ * @returns {boolean} `true` when both lease guard fields are present numbers.
292
+ */
199
293
  const isSendLeaseRemove = (opts) => {
200
294
  return opts && typeof opts.leaseTries === 'number' && typeof opts.leaseSendingAt === 'number';
201
295
  };
202
296
 
203
297
  export {
204
298
  debug,
299
+ escapeHtml,
205
300
  logError,
301
+ hasOwnProp,
206
302
  isPlainObject,
207
303
  deepMerge,
208
304
  equals,