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 +70 -18
- package/helpers.js +19 -0
- package/index.cjs +442 -119
- package/index.d.cts +50 -7
- package/index.d.ts +50 -7
- package/index.js +423 -118
- package/package.json +19 -4
- package/presets.js +2 -2
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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
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.
|
|
375
|
-
| `sendingTimeout` | `300000` (5 min) | Stale-lock recovery window. Must exceed worst-case SMTP roundtrip;
|
|
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
|
|
393
|
-
- `{{{key}}}` — raw
|
|
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.
|
|
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`
|
|
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 })`
|
|
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 [
|
|
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
|
+
'&': '&',
|
|
225
|
+
'<': '<',
|
|
226
|
+
'>': '>',
|
|
227
|
+
'"': '"',
|
|
228
|
+
"'": ''',
|
|
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,
|