mail-time 4.1.0 → 5.1.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
@@ -19,7 +19,7 @@ Bulletproof email queue for [horizontally scaled](#sending-emails-from-a-cluster
19
19
 
20
20
  `MailTime` runs in one of two modes:
21
21
 
22
- - **`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.
23
23
  - **`client`** — only enqueues emails. Use for app servers in a "dedicated mail micro-service" topology.
24
24
 
25
25
  Many clients + one or more servers coexist behind the same `prefix` in the same store.
@@ -29,7 +29,7 @@ Many clients + one or more servers coexist behind the same `prefix` in the same
29
29
  - 🏢 **Horizontally scaled** — synchronize one queue across N processes/hosts/DCs.
30
30
  - 🔁 **Multi-SMTP rotation** — `backup` (failover) and `balancer` (round-robin) strategies.
31
31
  - 💪 **Built-in retries** — storage-backed retry with per-letter transport pinning.
32
- - 🎯 **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.
33
33
  - 📮 **Email concatenation** — fold same-`to` emails arriving inside a window into one letter.
34
34
  - 🎛️ **One-line setup** — built-in [presets](#settings-presets) for `transactional`, `otp`, `newsletter`, `marketing`, `notifications`, `alerts`.
35
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).
@@ -84,7 +84,7 @@ MailTime keeps every letter in the queue until SMTP confirms delivery.
84
84
 
85
85
  ### Sending emails from a cluster
86
86
 
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 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).
88
88
 
89
89
  ```ascii
90
90
  |===================THE=CLUSTER===================| |=QUEUE=|
@@ -271,9 +271,7 @@ process.on('SIGTERM', async () => {
271
271
  });
272
272
  ```
273
273
 
274
- Or explicitly: `mailQueue.destroy(); await mailQueue.drain();`
275
-
276
- `destroy()` is idempotent and stops the scheduler. Always call it from tests; pair with `drain()` when iterate-driven sends ran.
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.
277
275
 
278
276
  #### Pause / resume a server (backpressure)
279
277
 
@@ -300,7 +298,7 @@ Queue storage and scheduler storage can be the same store or different ones. Use
300
298
 
301
299
  | Queue | Scheduler (`josk`) | Best for |
302
300
  | -------- | ------------------ | -------------------------------------------- |
303
- | Postgres | Postgres | Multi-DC, mixed clocks, strict exactly-once. |
301
+ | Postgres | Postgres | Multi-DC, mixed clocks, strongest consistency. |
304
302
  | Redis | Redis | High-throughput single-region. |
305
303
  | Mongo | Mongo | Apps already on Mongo (especially Meteor). |
306
304
  | Mongo | Redis | Durable letter storage + sub-second polling. |
@@ -338,11 +336,11 @@ const mailTime = new MailTime(mailTimePreset('otp', {
338
336
  | Preset | Shape | Best for |
339
337
  | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
340
338
  | `transactional` | `retries: 30`, `retryDelay: 10s`, `concatEmails: false`, `concurrency: 1`, `josk.zombieTime: 120s` | Receipts, password resets, account changes, welcome emails. |
341
- | `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. |
342
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. |
343
341
  | `marketing` | `retries: 10`, `retryDelay: 30s`, `concatEmails: false`, `concurrency: 5`, `josk.zombieTime: 3min` | Promotional / campaign blasts where each letter is unique. |
344
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. |
345
- | `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. |
346
344
 
347
345
  Presets are equally useful on `type: 'client'` instances — keys that don't apply to the client role are simply ignored.
348
346
 
@@ -371,8 +369,12 @@ Defaults fit moderate traffic in a single region. Reach for a [preset](#settings
371
369
  | Option | Default | Change when |
372
370
  | ---------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
373
371
  | `mode` | `'batch'` | `'one'` to claim a single row per tick (fairness over throughput across cluster nodes) |
374
- | `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. |
375
- | `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. |
376
378
  | `revolvingInterval` | `1536` ms | Lower → faster pickup; higher → less scheduler I/O |
377
379
  | `josk.minRevolvingDelay` / `maxRevolvingDelay` | `512` / `2048` | Lower both → snappier polls, more storage load |
378
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. |
@@ -385,12 +387,54 @@ Defaults fit moderate traffic in a single region. Reach for a [preset](#settings
385
387
 
386
388
  For deeper JoSk semantics (lease lifecycle, scheduler adapters, recurring tasks), install the JoSk skill: **`npx skills add veliovgroup/josk`**.
387
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
+
388
430
  ## Templates
389
431
 
390
432
  Two Mustache-like placeholder forms:
391
433
 
392
- - `{{key}}` — string interpolation, strips HTML from the value (safe for plain text).
393
- - `{{{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).
394
438
 
395
439
  Every `sendMail` option is available inside `text`, `html`, and the wrapping template:
396
440
 
@@ -441,12 +485,17 @@ await mailQueue.sendMail({
441
485
  | `concatDelay` | `number` (ms) | `60000` | Fold window. |
442
486
  | `revolvingInterval` | `number` (ms) | `1536` | Queue iteration interval. |
443
487
  | `mode` | `'one' \| 'batch'` | `'batch'` | `'batch'` claims every due row per tick; `'one'` claims a single row per tick. |
444
- | `concurrency` | `number` | `1` | Parallel SMTPs per instance. The CAS on `isSending` prevents duplicate delivery. |
445
- | `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). |
446
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. |
447
496
  | `template` | `string` | `'{{{html}}}'` | Default envelope. |
448
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>`. |
449
- | `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). |
450
499
  | `debug` | `boolean` | `false` | Verbose logs. |
451
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). |
452
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' }`. |
@@ -461,6 +510,7 @@ await mailQueue.sendMail({
461
510
  | `minRevolvingDelay` | `512` | Lower bound of poll window. |
462
511
  | `maxRevolvingDelay` | `2048` | Upper bound. |
463
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. |
464
514
  | `execute` | `'batch'` | JoSk scheduler batching; low impact for MailTime (one interval task per instance). |
465
515
  | `concurrency` | `Infinity` | Cap overlapping JoSk handler runs on **this** process (`1` if ticks pile up). |
466
516
  | `autoClear` | `false` | Remove orphan tasks from storage. |
@@ -478,21 +528,25 @@ For deeper JoSk semantics, install the JoSk skill: **`npx skills add veliovgroup
478
528
  - `cancel(uuid)` — alias of `cancelMail`.
479
529
  - `ping()` → `Promise<{status, code, statusCode, paused?, error?}>`. Pings scheduler then queue; `paused` reflects `isPaused`.
480
530
  - `ready()` → `Promise<MailTime>`. Awaits all startup work; rejects with `.cause` on storage failure.
481
- - `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.
482
532
  - `drain()` → `Promise<void>`. Resolves once every in-flight SMTP attempt finishes. Useful in tests and graceful-shutdown paths.
483
- - `pause()` / `resume()` → `boolean`. Server-only reversible backpressure; no-ops on `client` or after `destroy()`. See [Pause / resume](#pause--resume-a-server-backpressure).
533
+ - `pause()` / `resume()` → `boolean`. Server-only reversible backpressure; no-ops on `client` or after `destroy()`. See [Shutdown](#6-shutdown).
484
534
  - `isPaused` → `boolean`. Read-only; always `false` on `client`.
485
535
 
486
536
  ### Queue constructors
487
537
 
488
538
  | Constructor | Required option | Optional |
489
539
  | ---------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
490
- | `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. |
540
+ | `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. |
491
541
  | `new MongoQueue({ db, prefix? })` | `db` from `MongoClient#db()` | `prefix` — inherited from `MailTime` when omitted. Indexes auto-created on first `ready()`. |
492
542
  | `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()`. |
493
543
 
494
544
  For custom adapters see [docs/queue-api.md](https://github.com/veliovgroup/mail-time/blob/master/docs/queue-api.md).
495
545
 
546
+ ### Redis Cluster
547
+
548
+ 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 every Lua operation to one slot. 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](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v5-v5.1.md) before cutover.
549
+
496
550
  ### Module functions
497
551
 
498
552
  - `mailTimePreset(name, overrides?)` → fresh MailTime constructor config. Deep-clones the named [preset](#settings-presets) and deep-merges your overrides (scalars win, nested `josk` composes). Throws on unknown `name` or non-object `overrides`.
@@ -502,6 +556,7 @@ For custom adapters see [docs/queue-api.md](https://github.com/veliovgroup/mail-
502
556
  ### Static
503
557
 
504
558
  - `MailTime.Template` — get/set the default HTML envelope template.
559
+ - `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`.
505
560
 
506
561
  ## Migrations
507
562
 
@@ -511,6 +566,8 @@ Upgrade checklists, adapter contract changes, and rollout notes live in the docs
511
566
  |------|-----|------------|
512
567
  | 3.x | 4.1 | [v3 → v4](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v3-v4.md) |
513
568
  | 4.0 | 4.1 | [v4.0 → v4.1](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4-v4.1.md) |
569
+ | 4.1 | 5.0 | [v4.1 → v5](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4.1-v5.md) |
570
+ | 5.0 | 5.1 | [v5 → v5.1 Redis Cluster](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v5-v5.1.md) |
514
571
 
515
572
  New in v4 (opt-in): `mailTimePreset`, `concurrency`/`mode`, `sendingTimeout`, `drain()`/`pause()`/`resume()`, per-recipient handling, AI skills bundle.
516
573
 
@@ -4,7 +4,17 @@ export type RedisClient = {
4
4
  set: (key: string, value: string, options?: object) => Promise<unknown>;
5
5
  del: (key: string | string[]) => Promise<number>;
6
6
  ping: () => Promise<string>;
7
- scanIterator: (options: object) => AsyncIterable<string | string[]>;
7
+ scanIterator?: ((options: object) => AsyncIterable<string | string[]>) | undefined;
8
+ hGet?: ((key: string, field: string) => Promise<string | null>) | undefined;
9
+ eval?: ((script: string, options: {
10
+ keys: string[];
11
+ arguments: string[];
12
+ }) => Promise<unknown>) | undefined;
13
+ evalSha?: ((sha: string, options: {
14
+ keys: string[];
15
+ arguments: string[];
16
+ }) => Promise<unknown>) | undefined;
17
+ scriptLoad?: ((script: string) => Promise<string>) | undefined;
8
18
  watch?: ((key: string) => Promise<unknown>) | undefined;
9
19
  unwatch?: (() => Promise<unknown>) | undefined;
10
20
  multi?: (() => object) | undefined;
@@ -12,6 +22,10 @@ export type RedisClient = {
12
22
  export type RedisQueueOption = {
13
23
  client: RedisClient;
14
24
  prefix?: string | undefined;
25
+ /**
26
+ * - Use Redis Cluster hash-tag keys (`mailtime:{prefix}:*`). Default keeps existing standalone keys.
27
+ */
28
+ useHashTags?: boolean | undefined;
15
29
  };
16
30
  /** Class representing Redis Queue for MailTime */
17
31
  export class RedisQueue {
@@ -22,8 +36,12 @@ export class RedisQueue {
22
36
  constructor(opts: RedisQueueOption);
23
37
  name: string;
24
38
  client: RedisClient;
39
+ useHashTags: boolean;
25
40
  prefix: any;
26
41
  uniqueName: string | undefined;
42
+ lettersKey: string | undefined;
43
+ scheduleKey: string | undefined;
44
+ concatKeysKey: string | undefined;
27
45
  /**
28
46
  * @async
29
47
  * @memberOf RedisQueue