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 +145 -35
- package/adapters/mongo.js +6 -3
- package/adapters/postgres.js +19 -3
- package/helpers.js +105 -9
- package/index.cjs +629 -141
- package/index.d.cts +74 -9
- package/index.d.ts +74 -9
- package/index.js +497 -121
- package/package.json +32 -5
- package/presets.d.ts +4 -4
- package/presets.js +6 -6
package/README.md
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
|
|
2
2
|
# MailTime
|
|
3
3
|
|
|
4
|
-
[![npm version]
|
|
5
|
-
[![
|
|
6
|
-
[![
|
|
7
|
-
[![
|
|
8
|
-
[![
|
|
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.
|
|
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,
|
|
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
|
-
- 🧪 **
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
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.
|
|
342
|
-
| `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. |
|
|
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
|
|
360
|
-
- `{{{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).
|
|
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.
|
|
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`
|
|
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 })`
|
|
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
|
-
##
|
|
557
|
+
## Migrations
|
|
472
558
|
|
|
473
|
-
|
|
559
|
+
Upgrade checklists, adapter contract changes, and rollout notes live in the docs (no duplication here):
|
|
474
560
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
3.
|
|
478
|
-
4.
|
|
479
|
-
|
|
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
|
|
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
|
-
|
|
232
|
-
|
|
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
|
}
|
package/adapters/postgres.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)', [
|
|
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
|
-
*
|
|
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.
|
|
155
|
-
*
|
|
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
|
+
'&': '&',
|
|
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
|
+
|
|
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
|
-
/**
|
|
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,
|