mail-time 4.1.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
@@ -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,9 +528,9 @@ 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
@@ -502,6 +552,7 @@ For custom adapters see [docs/queue-api.md](https://github.com/veliovgroup/mail-
502
552
  ### Static
503
553
 
504
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`.
505
556
 
506
557
  ## Migrations
507
558
 
@@ -511,6 +562,7 @@ Upgrade checklists, adapter contract changes, and rollout notes live in the docs
511
562
  |------|-----|------------|
512
563
  | 3.x | 4.1 | [v3 → v4](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v3-v4.md) |
513
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) |
514
566
 
515
567
  New in v4 (opt-in): `mailTimePreset`, `concurrency`/`mode`, `sendingTimeout`, `drain()`/`pause()`/`resume()`, per-recipient handling, AI skills bundle.
516
568
 
package/helpers.js CHANGED
@@ -220,6 +220,24 @@ const filterAddressField = (field, acceptedSet) => {
220
220
  return field;
221
221
  };
222
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
+
223
241
  /**
224
242
  * @name isSendClaimUpdate - Detect an atomic send-claim update (`{ isSending: true, tries: N }`).
225
243
  * @function
@@ -278,6 +296,7 @@ const isSendLeaseRemove = (opts) => {
278
296
 
279
297
  export {
280
298
  debug,
299
+ escapeHtml,
281
300
  logError,
282
301
  hasOwnProp,
283
302
  isPlainObject,