mail-time 4.0.0 โ 4.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 +77 -19
- package/adapters/mongo.js +6 -3
- package/adapters/postgres.js +19 -3
- package/helpers.js +86 -9
- package/index.cjs +192 -27
- package/index.d.cts +24 -2
- package/index.d.ts +24 -2
- package/index.js +80 -9
- package/package.json +15 -3
- package/presets.d.ts +4 -4
- package/presets.js +4 -4
package/README.md
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
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
|
|
|
@@ -28,7 +36,7 @@ Many clients + one or more servers coexist behind the same `prefix` in the same
|
|
|
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
|
|
|
@@ -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.
|
|
@@ -261,6 +275,25 @@ Or explicitly: `mailQueue.destroy(); await mailQueue.drain();`
|
|
|
261
275
|
|
|
262
276
|
`destroy()` is idempotent and stops the scheduler. Always call it from tests; pair with `drain()` when iterate-driven sends ran.
|
|
263
277
|
|
|
278
|
+
#### Pause / resume a server (backpressure)
|
|
279
|
+
|
|
280
|
+
`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.
|
|
281
|
+
|
|
282
|
+
```js
|
|
283
|
+
mailQueue.pause(); // stop draining on this pod
|
|
284
|
+
// ...SMTP provider rate-limit clears / maintenance window ends...
|
|
285
|
+
mailQueue.resume(); // resume; an immediate scan kicks off
|
|
286
|
+
|
|
287
|
+
mailQueue.isPaused; // boolean
|
|
288
|
+
(await mailQueue.ping()).paused // boolean โ observable in health checks
|
|
289
|
+
|
|
290
|
+
// Stop draining AND wait for in-flight sends to settle:
|
|
291
|
+
mailQueue.pause();
|
|
292
|
+
await mailQueue.drain();
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
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.
|
|
296
|
+
|
|
264
297
|
## Storage layouts
|
|
265
298
|
|
|
266
299
|
Queue storage and scheduler storage can be the same store or different ones. Use this matrix:
|
|
@@ -443,10 +476,12 @@ For deeper JoSk semantics, install the JoSk skill: **`npx skills add veliovgroup
|
|
|
443
476
|
- `send(opts)` โ alias of `sendMail`.
|
|
444
477
|
- `cancelMail(uuidOrPromise)` โ `Promise<boolean>`. Accepts the `uuid` or the `Promise<string>` from `sendMail`.
|
|
445
478
|
- `cancel(uuid)` โ alias of `cancelMail`.
|
|
446
|
-
- `ping()` โ `Promise<{status, code, statusCode, error?}>`. Pings scheduler then queue
|
|
479
|
+
- `ping()` โ `Promise<{status, code, statusCode, paused?, error?}>`. Pings scheduler then queue; `paused` reflects `isPaused`.
|
|
447
480
|
- `ready()` โ `Promise<MailTime>`. Awaits all startup work; rejects with `.cause` on storage failure.
|
|
448
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.
|
|
449
482
|
- `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).
|
|
484
|
+
- `isPaused` โ `boolean`. Read-only; always `false` on `client`.
|
|
450
485
|
|
|
451
486
|
### Queue constructors
|
|
452
487
|
|
|
@@ -468,18 +503,16 @@ For custom adapters see [docs/queue-api.md](https://github.com/veliovgroup/mail-
|
|
|
468
503
|
|
|
469
504
|
- `MailTime.Template` โ get/set the default HTML envelope template.
|
|
470
505
|
|
|
471
|
-
##
|
|
506
|
+
## Migrations
|
|
472
507
|
|
|
473
|
-
|
|
508
|
+
Upgrade checklists, adapter contract changes, and rollout notes live in the docs (no duplication here):
|
|
474
509
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
3.
|
|
478
|
-
4.
|
|
479
|
-
5. **Custom queue adapters** โ `update`'s claim guard now triggers on `{ isSending: true, sendingAt, tries }` (was `{ isSent: true, tries }`) and must include the stale-lock-recovery clause `(isSending === false OR sendingAt <= now - sendingTimeout)`. The iterate path must call `await mailTimeInstance.___dispatch(row)` instead of `___send` and honor `opts.limit` / `opts.sendingTimeout`. See [docs/queue-api.md](https://github.com/veliovgroup/mail-time/blob/master/docs/queue-api.md) and [`adapters/blank-example.js`](https://github.com/veliovgroup/mail-time/blob/master/adapters/blank-example.js).
|
|
480
|
-
6. **Default behavior unchanged** โ `concurrency: 1` keeps the post-upgrade send rate identical to v3. Opt into parallel sends by raising `concurrency`.
|
|
510
|
+
| From | To | Full guide |
|
|
511
|
+
|------|-----|------------|
|
|
512
|
+
| 3.x | 4.1 | [v3 โ v4](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v3-v4.md) |
|
|
513
|
+
| 4.0 | 4.1 | [v4.0 โ v4.1](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4-v4.1.md) |
|
|
481
514
|
|
|
482
|
-
New v4
|
|
515
|
+
New in v4 (opt-in): `mailTimePreset`, `concurrency`/`mode`, `sendingTimeout`, `drain()`/`pause()`/`resume()`, per-recipient handling, AI skills bundle.
|
|
483
516
|
|
|
484
517
|
## Testing
|
|
485
518
|
|
|
@@ -524,3 +557,28 @@ Mixed clusters (some Node, some Bun) share one schedule under the same `prefix`
|
|
|
524
557
|
- [PayPal](https://paypal.me/veliovgroup).
|
|
525
558
|
- Try [โ๏ธ meteor-files.com](https://meteor-files.com/?ref=github-mail-time-repo-footer).
|
|
526
559
|
- Try [โฒ ostr.io](https://ostr.io?ref=github-mail-time-repo-footer) for server monitoring, web analytics, web-CRON, and SEO pre-rendering.
|
|
560
|
+
|
|
561
|
+
[npm-url]: https://www.npmjs.com/package/mail-time
|
|
562
|
+
[badge-npm-v]: https://img.shields.io/npm/v/mail-time.svg
|
|
563
|
+
[badge-npm-dm]: https://img.shields.io/npm/dm/mail-time.svg
|
|
564
|
+
[badge-ci]: https://github.com/veliovgroup/mail-time/actions/workflows/ci.yml/badge.svg?branch=master
|
|
565
|
+
[ci-url]: https://github.com/veliovgroup/mail-time/actions/workflows/ci.yml
|
|
566
|
+
[badge-size]: https://img.shields.io/bundlephobia/minzip/mail-time
|
|
567
|
+
[size-url]: https://bundlephobia.com/package/mail-time
|
|
568
|
+
[badge-cov]: https://img.shields.io/badge/coverage-~99%25-brightgreen
|
|
569
|
+
[badge-license]: https://img.shields.io/badge/License-BSD%203--Clause-blue.svg
|
|
570
|
+
[license-url]: LICENSE
|
|
571
|
+
[badge-node]: https://img.shields.io/node/v/mail-time
|
|
572
|
+
[node-url]: https://nodejs.org/
|
|
573
|
+
[badge-ts]: https://img.shields.io/badge/TypeScript-ready-blue
|
|
574
|
+
[ts-url]: #quick-start
|
|
575
|
+
[badge-bun]: https://img.shields.io/badge/Bun-%3E%3D1.1.0-black?logo=bun
|
|
576
|
+
[bun-url]: #bun
|
|
577
|
+
[badge-meteor]: https://img.shields.io/badge/Meteor-ostrio%3Amailer-DE4F4F
|
|
578
|
+
[meteor-url]: https://packosphere.com/ostrio/mailer
|
|
579
|
+
[badge-deps]: https://img.shields.io/badge/dependencies-1-brightgreen
|
|
580
|
+
[deps-url]: https://www.npmjs.com/package/mail-time?activeTab=dependencies
|
|
581
|
+
[badge-sponsor]: https://img.shields.io/github/sponsors/dr-dimitru?label=Sponsor
|
|
582
|
+
[sponsor-url]: https://github.com/sponsors/dr-dimitru
|
|
583
|
+
[badge-donate]: https://img.shields.io/badge/Donate-PayPal-00457C?logo=paypal&logoColor=white
|
|
584
|
+
[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,43 @@ const filterAddressField = (field, acceptedSet) => {
|
|
|
175
220
|
return field;
|
|
176
221
|
};
|
|
177
222
|
|
|
223
|
+
/**
|
|
224
|
+
* @name isSendClaimUpdate - Detect an atomic send-claim update (`{ isSending: true, tries: N }`).
|
|
225
|
+
* @function
|
|
226
|
+
* @param {object} updateObj
|
|
227
|
+
* @returns {boolean} `true` when `updateObj` claims a row for sending.
|
|
228
|
+
*/
|
|
178
229
|
const isSendClaimUpdate = (updateObj) => {
|
|
179
230
|
return updateObj && updateObj.isSending === true && typeof updateObj.tries === 'number';
|
|
180
231
|
};
|
|
181
232
|
|
|
233
|
+
/**
|
|
234
|
+
* @name isSendLeaseGuardedUpdate - Detect an update guarded by a send lease
|
|
235
|
+
* (carries `leaseTries` and `leaseSendingAt`).
|
|
236
|
+
* @function
|
|
237
|
+
* @param {object} updateObj
|
|
238
|
+
* @returns {boolean} `true` when both lease guard fields are present numbers.
|
|
239
|
+
*/
|
|
182
240
|
const isSendLeaseGuardedUpdate = (updateObj) => {
|
|
183
241
|
return updateObj && typeof updateObj.leaseTries === 'number' && typeof updateObj.leaseSendingAt === 'number';
|
|
184
242
|
};
|
|
185
243
|
|
|
244
|
+
/**
|
|
245
|
+
* @name isAppendMailOptionUpdate - Detect an update that appends a mail option (for email concatenation).
|
|
246
|
+
* @function
|
|
247
|
+
* @param {object} updateObj
|
|
248
|
+
* @returns {boolean} `true` when `updateObj.appendMailOption` is set.
|
|
249
|
+
*/
|
|
186
250
|
const isAppendMailOptionUpdate = (updateObj) => {
|
|
187
251
|
return updateObj && updateObj.appendMailOption !== void 0;
|
|
188
252
|
};
|
|
189
253
|
|
|
190
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* @name stripInternalUpdateMeta - Strip MailTime-internal update keys before persisting to storage.
|
|
256
|
+
* @function
|
|
257
|
+
* @param {object} updateObj
|
|
258
|
+
* @returns {object} A shallow clone without `leaseTries`, `leaseSendingAt`, and `appendMailOption`.
|
|
259
|
+
*/
|
|
191
260
|
const stripInternalUpdateMeta = (updateObj) => {
|
|
192
261
|
const out = { ...updateObj };
|
|
193
262
|
delete out.leaseTries;
|
|
@@ -196,6 +265,13 @@ const stripInternalUpdateMeta = (updateObj) => {
|
|
|
196
265
|
return out;
|
|
197
266
|
};
|
|
198
267
|
|
|
268
|
+
/**
|
|
269
|
+
* @name isSendLeaseRemove - Detect a remove guarded by a send lease
|
|
270
|
+
* (carries `leaseTries` and `leaseSendingAt`).
|
|
271
|
+
* @function
|
|
272
|
+
* @param {object} opts
|
|
273
|
+
* @returns {boolean} `true` when both lease guard fields are present numbers.
|
|
274
|
+
*/
|
|
199
275
|
const isSendLeaseRemove = (opts) => {
|
|
200
276
|
return opts && typeof opts.leaseTries === 'number' && typeof opts.leaseSendingAt === 'number';
|
|
201
277
|
};
|
|
@@ -203,6 +279,7 @@ const isSendLeaseRemove = (opts) => {
|
|
|
203
279
|
export {
|
|
204
280
|
debug,
|
|
205
281
|
logError,
|
|
282
|
+
hasOwnProp,
|
|
206
283
|
isPlainObject,
|
|
207
284
|
deepMerge,
|
|
208
285
|
equals,
|
package/index.cjs
CHANGED
|
@@ -1,20 +1,48 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const josk = require('josk');
|
|
4
|
-
const
|
|
4
|
+
const crypto = require('crypto');
|
|
5
5
|
|
|
6
6
|
const hasOwn = Object.prototype.hasOwnProperty;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* @name hasOwnProp - `Object.hasOwn` (ES2022) polyfill.
|
|
10
|
+
* @function
|
|
11
|
+
* @param {object} obj
|
|
12
|
+
* @param {PropertyKey} key
|
|
13
|
+
* @returns {boolean} `true` if the object has the property, `false` otherwise.
|
|
14
|
+
*/
|
|
15
|
+
const hasOwnProp = (obj, key) => hasOwn.call(obj, key);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @name debug - Debug logging.
|
|
19
|
+
* @function
|
|
20
|
+
* @param {boolean} isDebug
|
|
21
|
+
* @param {...any} args
|
|
22
|
+
* @returns {void}
|
|
23
|
+
*/
|
|
8
24
|
const debug = (isDebug, ...args) => {
|
|
9
25
|
if (isDebug) {
|
|
10
26
|
console.info('[DEBUG] [mail-time]', `${new Date()}`, ...args);
|
|
11
27
|
}
|
|
12
28
|
};
|
|
13
29
|
|
|
30
|
+
/**
|
|
31
|
+
* @name logError - Error logging.
|
|
32
|
+
* @function
|
|
33
|
+
* @param {...any} args
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
14
36
|
const logError = (...args) => {
|
|
15
37
|
console.error('[ERROR] [mail-time]', `${new Date()}`, ...args);
|
|
16
38
|
};
|
|
17
39
|
|
|
40
|
+
/**
|
|
41
|
+
* @name isPlainObject - Check whether a value is a plain object (literal or `Object.create(null)`).
|
|
42
|
+
* @function
|
|
43
|
+
* @param {any} value
|
|
44
|
+
* @returns {boolean} `true` for plain objects, `false` for `null`, arrays, class instances, and primitives.
|
|
45
|
+
*/
|
|
18
46
|
const isPlainObject = (value) => {
|
|
19
47
|
if (value === null || typeof value !== 'object') {
|
|
20
48
|
return false;
|
|
@@ -24,11 +52,15 @@ const isPlainObject = (value) => {
|
|
|
24
52
|
};
|
|
25
53
|
|
|
26
54
|
/**
|
|
27
|
-
* Minimal deep-merge sufficient for nodemailer-shaped mail options:
|
|
55
|
+
* @name deepMerge - Minimal deep-merge sufficient for nodemailer-shaped mail options:
|
|
28
56
|
* - plain objects merge key-by-key
|
|
29
57
|
* - arrays concatenate
|
|
30
58
|
* - other values (strings, numbers, Date, Buffer, streams, classes) replace
|
|
31
59
|
* Source values override target values.
|
|
60
|
+
* @function
|
|
61
|
+
* @param {any} target - Base value; merged into a shallow clone when a plain object, otherwise ignored.
|
|
62
|
+
* @param {any} source - Overriding value; returns `target` unchanged when not a plain object.
|
|
63
|
+
* @returns {any} The merged result (a new object), or `target` when `source` is not a plain object.
|
|
32
64
|
*/
|
|
33
65
|
const deepMerge = (target, source) => {
|
|
34
66
|
if (!isPlainObject(source)) {
|
|
@@ -54,9 +86,13 @@ const deepMerge = (target, source) => {
|
|
|
54
86
|
};
|
|
55
87
|
|
|
56
88
|
/**
|
|
57
|
-
* Order-insensitive deep equality. Treats arrays as multisets and
|
|
89
|
+
* @name equals - Order-insensitive deep equality. Treats arrays as multisets and
|
|
58
90
|
* objects as unordered maps. Designed for the small `mailOptions`
|
|
59
91
|
* shape used by MailTime's email concatenation dedup.
|
|
92
|
+
* @function
|
|
93
|
+
* @param {any} a
|
|
94
|
+
* @param {any} b
|
|
95
|
+
* @returns {boolean} `true` when `a` and `b` are deeply equal ignoring array/key order.
|
|
60
96
|
*/
|
|
61
97
|
const equals = (a, b) => {
|
|
62
98
|
if (a === b) {
|
|
@@ -115,9 +151,11 @@ const equals = (a, b) => {
|
|
|
115
151
|
};
|
|
116
152
|
|
|
117
153
|
/**
|
|
118
|
-
* Extract the email part of a nodemailer-shaped recipient entry.
|
|
154
|
+
* @name extractEmail - Extract the email part of a nodemailer-shaped recipient entry.
|
|
119
155
|
* Accepts `'a@x.com'`, `'Name <a@x.com>'`, or `{ name, address }`.
|
|
120
|
-
*
|
|
156
|
+
* @function
|
|
157
|
+
* @param {string|{name?: string, address?: string}|null|undefined} entry
|
|
158
|
+
* @returns {string|null} The address lowercased and trimmed, or `null` when none can be parsed.
|
|
121
159
|
*/
|
|
122
160
|
const extractEmail = (entry) => {
|
|
123
161
|
if (!entry) {
|
|
@@ -134,7 +172,10 @@ const extractEmail = (entry) => {
|
|
|
134
172
|
};
|
|
135
173
|
|
|
136
174
|
/**
|
|
137
|
-
* Normalize a `to`/`cc`/`bcc` field into a flat list of lowercase addresses.
|
|
175
|
+
* @name toAddressList - Normalize a `to`/`cc`/`bcc` field into a flat list of lowercase addresses.
|
|
176
|
+
* @function
|
|
177
|
+
* @param {string|Array<string|{name?: string, address?: string}>|null|undefined} field
|
|
178
|
+
* @returns {string[]} Flat list of parsed lowercase addresses; empty when `field` is falsy or unparseable.
|
|
138
179
|
*/
|
|
139
180
|
const toAddressList = (field) => {
|
|
140
181
|
if (!field) {
|
|
@@ -155,9 +196,13 @@ const toAddressList = (field) => {
|
|
|
155
196
|
};
|
|
156
197
|
|
|
157
198
|
/**
|
|
158
|
-
* Remove entries whose extracted address is in `acceptedSet` from a
|
|
159
|
-
* nodemailer `to`/`cc`/`bcc` field.
|
|
160
|
-
*
|
|
199
|
+
* @name filterAddressField - Remove entries whose extracted address is in `acceptedSet` from a
|
|
200
|
+
* nodemailer `to`/`cc`/`bcc` field.
|
|
201
|
+
* @function
|
|
202
|
+
* @param {string|Array<string|{name?: string, address?: string}>|null|undefined} field
|
|
203
|
+
* @param {Set<string>} acceptedSet - Lowercase addresses to drop.
|
|
204
|
+
* @returns {string|Array|undefined} The filtered field, or `void 0` when the filtered array would be
|
|
205
|
+
* empty or the single string is dropped. Returns `field` unchanged when `acceptedSet` is empty.
|
|
161
206
|
*/
|
|
162
207
|
const filterAddressField = (field, acceptedSet) => {
|
|
163
208
|
if (!field || !(acceptedSet instanceof Set) || acceptedSet.size === 0) {
|
|
@@ -180,19 +225,43 @@ const filterAddressField = (field, acceptedSet) => {
|
|
|
180
225
|
return field;
|
|
181
226
|
};
|
|
182
227
|
|
|
228
|
+
/**
|
|
229
|
+
* @name isSendClaimUpdate - Detect an atomic send-claim update (`{ isSending: true, tries: N }`).
|
|
230
|
+
* @function
|
|
231
|
+
* @param {object} updateObj
|
|
232
|
+
* @returns {boolean} `true` when `updateObj` claims a row for sending.
|
|
233
|
+
*/
|
|
183
234
|
const isSendClaimUpdate = (updateObj) => {
|
|
184
235
|
return updateObj && updateObj.isSending === true && typeof updateObj.tries === 'number';
|
|
185
236
|
};
|
|
186
237
|
|
|
238
|
+
/**
|
|
239
|
+
* @name isSendLeaseGuardedUpdate - Detect an update guarded by a send lease
|
|
240
|
+
* (carries `leaseTries` and `leaseSendingAt`).
|
|
241
|
+
* @function
|
|
242
|
+
* @param {object} updateObj
|
|
243
|
+
* @returns {boolean} `true` when both lease guard fields are present numbers.
|
|
244
|
+
*/
|
|
187
245
|
const isSendLeaseGuardedUpdate = (updateObj) => {
|
|
188
246
|
return updateObj && typeof updateObj.leaseTries === 'number' && typeof updateObj.leaseSendingAt === 'number';
|
|
189
247
|
};
|
|
190
248
|
|
|
249
|
+
/**
|
|
250
|
+
* @name isAppendMailOptionUpdate - Detect an update that appends a mail option (for email concatenation).
|
|
251
|
+
* @function
|
|
252
|
+
* @param {object} updateObj
|
|
253
|
+
* @returns {boolean} `true` when `updateObj.appendMailOption` is set.
|
|
254
|
+
*/
|
|
191
255
|
const isAppendMailOptionUpdate = (updateObj) => {
|
|
192
256
|
return updateObj && updateObj.appendMailOption !== void 0;
|
|
193
257
|
};
|
|
194
258
|
|
|
195
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* @name stripInternalUpdateMeta - Strip MailTime-internal update keys before persisting to storage.
|
|
261
|
+
* @function
|
|
262
|
+
* @param {object} updateObj
|
|
263
|
+
* @returns {object} A shallow clone without `leaseTries`, `leaseSendingAt`, and `appendMailOption`.
|
|
264
|
+
*/
|
|
196
265
|
const stripInternalUpdateMeta = (updateObj) => {
|
|
197
266
|
const out = { ...updateObj };
|
|
198
267
|
delete out.leaseTries;
|
|
@@ -201,6 +270,13 @@ const stripInternalUpdateMeta = (updateObj) => {
|
|
|
201
270
|
return out;
|
|
202
271
|
};
|
|
203
272
|
|
|
273
|
+
/**
|
|
274
|
+
* @name isSendLeaseRemove - Detect a remove guarded by a send lease
|
|
275
|
+
* (carries `leaseTries` and `leaseSendingAt`).
|
|
276
|
+
* @function
|
|
277
|
+
* @param {object} opts
|
|
278
|
+
* @returns {boolean} `true` when both lease guard fields are present numbers.
|
|
279
|
+
*/
|
|
204
280
|
const isSendLeaseRemove = (opts) => {
|
|
205
281
|
return opts && typeof opts.leaseTries === 'number' && typeof opts.leaseSendingAt === 'number';
|
|
206
282
|
};
|
|
@@ -425,10 +501,13 @@ class MongoQueue {
|
|
|
425
501
|
cursor.limit(limit);
|
|
426
502
|
}
|
|
427
503
|
|
|
428
|
-
|
|
429
|
-
|
|
504
|
+
try {
|
|
505
|
+
while (await cursor.hasNext()) {
|
|
506
|
+
await this.mailTimeInstance.___dispatch(await cursor.next());
|
|
507
|
+
}
|
|
508
|
+
} finally {
|
|
509
|
+
await cursor.close().catch(() => {});
|
|
430
510
|
}
|
|
431
|
-
await cursor.close();
|
|
432
511
|
} catch (iterateError) {
|
|
433
512
|
logError('[iterate] [while/await] [iterateError]', iterateError);
|
|
434
513
|
}
|
|
@@ -1197,7 +1276,21 @@ class RedisQueue {
|
|
|
1197
1276
|
*/
|
|
1198
1277
|
|
|
1199
1278
|
const DEFAULT_PREFIX = 'default';
|
|
1200
|
-
|
|
1279
|
+
|
|
1280
|
+
// Two-key advisory lock, mirroring josk's PostgresAdapter: a stable MailTime namespace
|
|
1281
|
+
// plus a per-prefix hash. `pg_advisory_lock(int4, int4)` lives in its own keyspace, isolated
|
|
1282
|
+
// from single-int callers in the same database; distinct prefixes get distinct lock IDs, so
|
|
1283
|
+
// co-tenant MailTime queues no longer serialize each other's schema setup.
|
|
1284
|
+
const ADVISORY_LOCK_NAMESPACE = 0x4D61696C; // 'Mail' in ASCII as int32
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* @internal
|
|
1288
|
+
* @param {string} prefix
|
|
1289
|
+
* @returns {number} signed int32 hash of the prefix string
|
|
1290
|
+
*/
|
|
1291
|
+
const advisoryLockKeyFor = (prefix) => {
|
|
1292
|
+
return crypto.createHash('sha256').update(prefix).digest().readInt32BE(0);
|
|
1293
|
+
};
|
|
1201
1294
|
|
|
1202
1295
|
const fieldMap = {
|
|
1203
1296
|
to: 'to_address',
|
|
@@ -1300,7 +1393,8 @@ class PostgresQueue {
|
|
|
1300
1393
|
|
|
1301
1394
|
/** @internal */
|
|
1302
1395
|
async __setup() {
|
|
1303
|
-
|
|
1396
|
+
const advisoryLockKey = advisoryLockKeyFor(this.prefix);
|
|
1397
|
+
await this.client.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
|
|
1304
1398
|
|
|
1305
1399
|
try {
|
|
1306
1400
|
await this.client.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
|
|
@@ -1331,7 +1425,7 @@ class PostgresQueue {
|
|
|
1331
1425
|
await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_pending_to
|
|
1332
1426
|
ON mail_time_queue (prefix, to_address, is_sent, is_failed, is_cancelled, send_at)`);
|
|
1333
1427
|
} finally {
|
|
1334
|
-
await this.client.query('SELECT pg_advisory_unlock($1)', [
|
|
1428
|
+
await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
|
|
1335
1429
|
}
|
|
1336
1430
|
}
|
|
1337
1431
|
|
|
@@ -1713,7 +1807,7 @@ function defaultPresetOnError(error, email, info) {
|
|
|
1713
1807
|
* @property {number} [sendingTimeout]
|
|
1714
1808
|
* @property {'one' | 'batch'} [mode]
|
|
1715
1809
|
* @property {number} [concurrency]
|
|
1716
|
-
* @property {(error: unknown, email:
|
|
1810
|
+
* @property {(error: unknown, email: any, details?: object) => void} [onError]
|
|
1717
1811
|
* @property {object} [josk]
|
|
1718
1812
|
*/
|
|
1719
1813
|
|
|
@@ -1863,12 +1957,12 @@ const presetNames = Object.freeze(/** @type {MailTimePresetName[]} */ (Object.ke
|
|
|
1863
1957
|
*
|
|
1864
1958
|
* @param {MailTimePresetName} name - one of `presetNames`
|
|
1865
1959
|
* @param {object} [overrides] - additional MailTime constructor options
|
|
1866
|
-
* @returns {
|
|
1960
|
+
* @returns {MailTimePresetConfig} fresh, mutable MailTime constructor options (preset deep-cloned + overrides merged)
|
|
1867
1961
|
* @throws {Error} when `name` is unknown
|
|
1868
1962
|
* @throws {TypeError} when `overrides` is provided but not a plain object
|
|
1869
1963
|
*/
|
|
1870
1964
|
const mailTimePreset = (name, overrides) => {
|
|
1871
|
-
if (typeof name !== 'string' || !
|
|
1965
|
+
if (typeof name !== 'string' || !hasOwnProp(PRESETS, name)) {
|
|
1872
1966
|
throw new Error(`[mail-time] [mailTimePreset] unknown preset "${name}". Available: ${presetNames.join(', ')}`);
|
|
1873
1967
|
}
|
|
1874
1968
|
if (overrides !== void 0 && !isPlainObject(overrides)) {
|
|
@@ -2010,7 +2104,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
2010
2104
|
*/
|
|
2011
2105
|
|
|
2012
2106
|
/**
|
|
2013
|
-
* @typedef {{ status: string, code: number, statusCode: number, error?: unknown }} MailTimePingResult
|
|
2107
|
+
* @typedef {{ status: string, code: number, statusCode: number, paused?: boolean, error?: unknown }} MailTimePingResult
|
|
2014
2108
|
*/
|
|
2015
2109
|
|
|
2016
2110
|
/**
|
|
@@ -2121,6 +2215,8 @@ class MailTime {
|
|
|
2121
2215
|
this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
|
|
2122
2216
|
this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
|
|
2123
2217
|
this.__isDestroyed = false;
|
|
2218
|
+
this.__abortInFlight = false;
|
|
2219
|
+
this.__isPaused = false;
|
|
2124
2220
|
this.__readyPromise = null;
|
|
2125
2221
|
this.__schedulerTimer = null;
|
|
2126
2222
|
this.__inFlight = new Set();
|
|
@@ -2273,10 +2369,11 @@ class MailTime {
|
|
|
2273
2369
|
if (this.scheduler) {
|
|
2274
2370
|
const schedulerPing = await this.scheduler.ping();
|
|
2275
2371
|
if (schedulerPing.status !== 'OK') {
|
|
2276
|
-
return schedulerPing;
|
|
2372
|
+
return { ...schedulerPing, paused: this.__isPaused };
|
|
2277
2373
|
}
|
|
2278
2374
|
}
|
|
2279
|
-
|
|
2375
|
+
const queuePing = await this.queue.ping();
|
|
2376
|
+
return { ...queuePing, paused: this.__isPaused };
|
|
2280
2377
|
}
|
|
2281
2378
|
|
|
2282
2379
|
/**
|
|
@@ -2294,7 +2391,7 @@ class MailTime {
|
|
|
2294
2391
|
/**
|
|
2295
2392
|
* @memberOf MailTime
|
|
2296
2393
|
* @name destroy
|
|
2297
|
-
* @description Stop the scheduler and block future dispatches. Pass `{ drain: true }` to
|
|
2394
|
+
* @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, any in-flight SMTP attempts are neutralized on completion โ they perform no storage writes, no `onSent`/`onError` callbacks, and no logging once `destroy()` returns; their claimed rows are recovered by stale-lock timeout (`sendingTimeout`). Pass `{ drain: true }` to instead let in-flight attempts run to completion (returns a Promise that resolves once they settle).
|
|
2298
2395
|
* @param {{ drain?: boolean }} [opts]
|
|
2299
2396
|
* @returns {boolean | Promise<boolean>}
|
|
2300
2397
|
*/
|
|
@@ -2305,12 +2402,21 @@ class MailTime {
|
|
|
2305
2402
|
}
|
|
2306
2403
|
|
|
2307
2404
|
this.__isDestroyed = true;
|
|
2405
|
+
this.__isPaused = false;
|
|
2308
2406
|
if (this.scheduler && typeof this.scheduler.destroy === 'function') {
|
|
2309
2407
|
this.scheduler.destroy();
|
|
2310
2408
|
}
|
|
2311
2409
|
if (opts && opts.drain === true && this.__pool) {
|
|
2312
2410
|
return this.__pool.drain().then(() => true);
|
|
2313
2411
|
}
|
|
2412
|
+
// No graceful drain requested: neutralize in-flight SMTP completions so a
|
|
2413
|
+
// destroyed instance performs no storage writes, no `onSent`/`onError`
|
|
2414
|
+
// callbacks, and no logging after teardown. Each claimed row keeps its
|
|
2415
|
+
// `isSending` lock, which a peer or a future instance recovers once
|
|
2416
|
+
// `sendingTimeout` elapses โ identical to a crash mid-send. Use
|
|
2417
|
+
// `destroy({ drain: true })` (or `await drain()` before `destroy()`) when
|
|
2418
|
+
// in-flight sends must run to completion.
|
|
2419
|
+
this.__abortInFlight = true;
|
|
2314
2420
|
return true;
|
|
2315
2421
|
}
|
|
2316
2422
|
|
|
@@ -2328,6 +2434,52 @@ class MailTime {
|
|
|
2328
2434
|
}
|
|
2329
2435
|
}
|
|
2330
2436
|
|
|
2437
|
+
/**
|
|
2438
|
+
* @memberOf MailTime
|
|
2439
|
+
* @name pause
|
|
2440
|
+
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
2441
|
+
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
2442
|
+
*/
|
|
2443
|
+
pause() {
|
|
2444
|
+
this.__debug('[pause]');
|
|
2445
|
+
if (this.__isDestroyed || !this.scheduler) {
|
|
2446
|
+
return false;
|
|
2447
|
+
}
|
|
2448
|
+
const paused = this.scheduler.pause();
|
|
2449
|
+
if (paused) {
|
|
2450
|
+
this.__isPaused = true;
|
|
2451
|
+
}
|
|
2452
|
+
return paused;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
/**
|
|
2456
|
+
* @memberOf MailTime
|
|
2457
|
+
* @name resume
|
|
2458
|
+
* @description Resume competing for the queue-drain lease after `pause()`; triggers an immediate scan. No-op on `client` instances, after `destroy()`, or when not paused.
|
|
2459
|
+
* @returns {boolean} `true` if newly resumed; `false` if not paused, a client instance, or destroyed
|
|
2460
|
+
*/
|
|
2461
|
+
resume() {
|
|
2462
|
+
this.__debug('[resume]');
|
|
2463
|
+
if (this.__isDestroyed || !this.scheduler) {
|
|
2464
|
+
return false;
|
|
2465
|
+
}
|
|
2466
|
+
const resumed = this.scheduler.resume();
|
|
2467
|
+
if (resumed) {
|
|
2468
|
+
this.__isPaused = false;
|
|
2469
|
+
}
|
|
2470
|
+
return resumed;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
/**
|
|
2474
|
+
* @memberOf MailTime
|
|
2475
|
+
* @name isPaused
|
|
2476
|
+
* @description Whether this instance is currently paused from draining the queue. Always `false` on `client` instances.
|
|
2477
|
+
* @returns {boolean}
|
|
2478
|
+
*/
|
|
2479
|
+
get isPaused() {
|
|
2480
|
+
return this.__isPaused;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2331
2483
|
/**
|
|
2332
2484
|
* @memberOf MailTime
|
|
2333
2485
|
* @name send
|
|
@@ -2445,6 +2597,10 @@ class MailTime {
|
|
|
2445
2597
|
*/
|
|
2446
2598
|
async ___handleError(task, error, info) {
|
|
2447
2599
|
this.__debug('[private handleError]', { task, error, info });
|
|
2600
|
+
if (this.__abortInFlight) {
|
|
2601
|
+
this.__debug('[private handleError] instance destroyed; skipping onError', task?.uuid);
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2448
2604
|
if (!task) {
|
|
2449
2605
|
return;
|
|
2450
2606
|
}
|
|
@@ -2521,7 +2677,7 @@ class MailTime {
|
|
|
2521
2677
|
this.transport = transportIndex;
|
|
2522
2678
|
}
|
|
2523
2679
|
const task = {
|
|
2524
|
-
uuid:
|
|
2680
|
+
uuid: crypto.randomUUID(),
|
|
2525
2681
|
tries: 0,
|
|
2526
2682
|
isSent: false,
|
|
2527
2683
|
sendAt: opts.sendAt,
|
|
@@ -2558,7 +2714,7 @@ class MailTime {
|
|
|
2558
2714
|
if (matchHTML) {
|
|
2559
2715
|
for (let i = 0; i < matchHTML.length; i++) {
|
|
2560
2716
|
const key = matchHTML[i].slice(3, -3).trim();
|
|
2561
|
-
if (
|
|
2717
|
+
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
2562
2718
|
string = string.replace(matchHTML[i], `${replacements[key]}`);
|
|
2563
2719
|
}
|
|
2564
2720
|
}
|
|
@@ -2568,7 +2724,7 @@ class MailTime {
|
|
|
2568
2724
|
if (matchStr) {
|
|
2569
2725
|
for (let i = 0; i < matchStr.length; i++) {
|
|
2570
2726
|
const key = matchStr[i].slice(2, -2).trim();
|
|
2571
|
-
if (
|
|
2727
|
+
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
2572
2728
|
string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
|
|
2573
2729
|
}
|
|
2574
2730
|
}
|
|
@@ -2803,6 +2959,11 @@ class MailTime {
|
|
|
2803
2959
|
|
|
2804
2960
|
await new Promise((resolve) => {
|
|
2805
2961
|
transport.sendMail(compiledOpts, async (error, info) => {
|
|
2962
|
+
if (this.__abortInFlight) {
|
|
2963
|
+
this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
|
|
2964
|
+
resolve();
|
|
2965
|
+
return;
|
|
2966
|
+
}
|
|
2806
2967
|
this.__debug('[private send] [sending]', { error, info });
|
|
2807
2968
|
try {
|
|
2808
2969
|
if (error) {
|
|
@@ -2921,6 +3082,10 @@ class MailTime {
|
|
|
2921
3082
|
});
|
|
2922
3083
|
});
|
|
2923
3084
|
} catch (e) {
|
|
3085
|
+
if (this.__abortInFlight) {
|
|
3086
|
+
this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
2924
3089
|
logError('Exception during runtime:', e);
|
|
2925
3090
|
await this.___handleError(task, e, {});
|
|
2926
3091
|
}
|
|
@@ -2935,7 +3100,7 @@ class MailTime {
|
|
|
2935
3100
|
*/
|
|
2936
3101
|
async ___iterate() {
|
|
2937
3102
|
this.__debug('[private iterate]');
|
|
2938
|
-
if (this.__isDestroyed) {
|
|
3103
|
+
if (this.__isDestroyed || this.__isPaused) {
|
|
2939
3104
|
return;
|
|
2940
3105
|
}
|
|
2941
3106
|
const limit = this.mode === 'one' ? 1 : Infinity;
|
package/index.d.cts
CHANGED
|
@@ -4,6 +4,7 @@ export type MailTimePingResult = {
|
|
|
4
4
|
status: string;
|
|
5
5
|
code: number;
|
|
6
6
|
statusCode: number;
|
|
7
|
+
paused?: boolean;
|
|
7
8
|
error?: unknown;
|
|
8
9
|
};
|
|
9
10
|
export type MailTimeStorageClient = {
|
|
@@ -130,7 +131,7 @@ export type MailTimeOptions = {
|
|
|
130
131
|
* @typedef {import('./presets.js').MailTimePresetConfig} MailTimePresetConfig
|
|
131
132
|
*/
|
|
132
133
|
/**
|
|
133
|
-
* @typedef {{ status: string, code: number, statusCode: number, error?: unknown }} MailTimePingResult
|
|
134
|
+
* @typedef {{ status: string, code: number, statusCode: number, paused?: boolean, error?: unknown }} MailTimePingResult
|
|
134
135
|
*/
|
|
135
136
|
/**
|
|
136
137
|
* @typedef {{ [key: string]: any, query?: (queryText: string, values?: unknown[]) => Promise<{ rows?: unknown[], rowCount?: number | null }> }} MailTimeStorageClient
|
|
@@ -238,7 +239,7 @@ export class MailTime {
|
|
|
238
239
|
/**
|
|
239
240
|
* @memberOf MailTime
|
|
240
241
|
* @name destroy
|
|
241
|
-
* @description Stop the scheduler and block future dispatches. Pass `{ drain: true }` to
|
|
242
|
+
* @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, any in-flight SMTP attempts are neutralized on completion โ they perform no storage writes, no `onSent`/`onError` callbacks, and no logging once `destroy()` returns; their claimed rows are recovered by stale-lock timeout (`sendingTimeout`). Pass `{ drain: true }` to instead let in-flight attempts run to completion (returns a Promise that resolves once they settle).
|
|
242
243
|
* @param {{ drain?: boolean }} [opts]
|
|
243
244
|
* @returns {boolean | Promise<boolean>}
|
|
244
245
|
*/
|
|
@@ -253,6 +254,27 @@ export class MailTime {
|
|
|
253
254
|
* @returns {Promise<void>}
|
|
254
255
|
*/
|
|
255
256
|
drain(): Promise<void>;
|
|
257
|
+
/**
|
|
258
|
+
* @memberOf MailTime
|
|
259
|
+
* @name pause
|
|
260
|
+
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
261
|
+
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
262
|
+
*/
|
|
263
|
+
pause(): boolean;
|
|
264
|
+
/**
|
|
265
|
+
* @memberOf MailTime
|
|
266
|
+
* @name resume
|
|
267
|
+
* @description Resume competing for the queue-drain lease after `pause()`; triggers an immediate scan. No-op on `client` instances, after `destroy()`, or when not paused.
|
|
268
|
+
* @returns {boolean} `true` if newly resumed; `false` if not paused, a client instance, or destroyed
|
|
269
|
+
*/
|
|
270
|
+
resume(): boolean;
|
|
271
|
+
/**
|
|
272
|
+
* @memberOf MailTime
|
|
273
|
+
* @name isPaused
|
|
274
|
+
* @description Whether this instance is currently paused from draining the queue. Always `false` on `client` instances.
|
|
275
|
+
* @returns {boolean}
|
|
276
|
+
*/
|
|
277
|
+
get isPaused(): boolean;
|
|
256
278
|
/**
|
|
257
279
|
* @memberOf MailTime
|
|
258
280
|
* @name send
|
package/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export type MailTimePingResult = {
|
|
|
4
4
|
status: string;
|
|
5
5
|
code: number;
|
|
6
6
|
statusCode: number;
|
|
7
|
+
paused?: boolean;
|
|
7
8
|
error?: unknown;
|
|
8
9
|
};
|
|
9
10
|
export type MailTimeStorageClient = {
|
|
@@ -130,7 +131,7 @@ export type MailTimeOptions = {
|
|
|
130
131
|
* @typedef {import('./presets.js').MailTimePresetConfig} MailTimePresetConfig
|
|
131
132
|
*/
|
|
132
133
|
/**
|
|
133
|
-
* @typedef {{ status: string, code: number, statusCode: number, error?: unknown }} MailTimePingResult
|
|
134
|
+
* @typedef {{ status: string, code: number, statusCode: number, paused?: boolean, error?: unknown }} MailTimePingResult
|
|
134
135
|
*/
|
|
135
136
|
/**
|
|
136
137
|
* @typedef {{ [key: string]: any, query?: (queryText: string, values?: unknown[]) => Promise<{ rows?: unknown[], rowCount?: number | null }> }} MailTimeStorageClient
|
|
@@ -238,7 +239,7 @@ export class MailTime {
|
|
|
238
239
|
/**
|
|
239
240
|
* @memberOf MailTime
|
|
240
241
|
* @name destroy
|
|
241
|
-
* @description Stop the scheduler and block future dispatches. Pass `{ drain: true }` to
|
|
242
|
+
* @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, any in-flight SMTP attempts are neutralized on completion โ they perform no storage writes, no `onSent`/`onError` callbacks, and no logging once `destroy()` returns; their claimed rows are recovered by stale-lock timeout (`sendingTimeout`). Pass `{ drain: true }` to instead let in-flight attempts run to completion (returns a Promise that resolves once they settle).
|
|
242
243
|
* @param {{ drain?: boolean }} [opts]
|
|
243
244
|
* @returns {boolean | Promise<boolean>}
|
|
244
245
|
*/
|
|
@@ -253,6 +254,27 @@ export class MailTime {
|
|
|
253
254
|
* @returns {Promise<void>}
|
|
254
255
|
*/
|
|
255
256
|
drain(): Promise<void>;
|
|
257
|
+
/**
|
|
258
|
+
* @memberOf MailTime
|
|
259
|
+
* @name pause
|
|
260
|
+
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
261
|
+
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
262
|
+
*/
|
|
263
|
+
pause(): boolean;
|
|
264
|
+
/**
|
|
265
|
+
* @memberOf MailTime
|
|
266
|
+
* @name resume
|
|
267
|
+
* @description Resume competing for the queue-drain lease after `pause()`; triggers an immediate scan. No-op on `client` instances, after `destroy()`, or when not paused.
|
|
268
|
+
* @returns {boolean} `true` if newly resumed; `false` if not paused, a client instance, or destroyed
|
|
269
|
+
*/
|
|
270
|
+
resume(): boolean;
|
|
271
|
+
/**
|
|
272
|
+
* @memberOf MailTime
|
|
273
|
+
* @name isPaused
|
|
274
|
+
* @description Whether this instance is currently paused from draining the queue. Always `false` on `client` instances.
|
|
275
|
+
* @returns {boolean}
|
|
276
|
+
*/
|
|
277
|
+
get isPaused(): boolean;
|
|
256
278
|
/**
|
|
257
279
|
* @memberOf MailTime
|
|
258
280
|
* @name send
|
package/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { JoSk, RedisAdapter, MongoAdapter, PostgresAdapter } from 'josk';
|
|
2
|
-
import { randomUUID } from '
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
3
|
|
|
4
4
|
import { MongoQueue } from './adapters/mongo.js';
|
|
5
5
|
import { RedisQueue } from './adapters/redis.js';
|
|
6
6
|
import { PostgresQueue } from './adapters/postgres.js';
|
|
7
7
|
import { mailTimePreset, presets, presetNames } from './presets.js';
|
|
8
|
-
import { debug, logError, deepMerge, equals, isPlainObject, extractEmail, toAddressList, filterAddressField } from './helpers.js';
|
|
8
|
+
import { debug, logError, hasOwnProp, deepMerge, equals, isPlainObject, extractEmail, toAddressList, filterAddressField } from './helpers.js';
|
|
9
9
|
|
|
10
10
|
const noop = () => {};
|
|
11
11
|
const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
|
|
@@ -139,7 +139,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
139
139
|
*/
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
|
-
* @typedef {{ status: string, code: number, statusCode: number, error?: unknown }} MailTimePingResult
|
|
142
|
+
* @typedef {{ status: string, code: number, statusCode: number, paused?: boolean, error?: unknown }} MailTimePingResult
|
|
143
143
|
*/
|
|
144
144
|
|
|
145
145
|
/**
|
|
@@ -250,6 +250,8 @@ class MailTime {
|
|
|
250
250
|
this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
|
|
251
251
|
this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
|
|
252
252
|
this.__isDestroyed = false;
|
|
253
|
+
this.__abortInFlight = false;
|
|
254
|
+
this.__isPaused = false;
|
|
253
255
|
this.__readyPromise = null;
|
|
254
256
|
this.__schedulerTimer = null;
|
|
255
257
|
this.__inFlight = new Set();
|
|
@@ -402,10 +404,11 @@ class MailTime {
|
|
|
402
404
|
if (this.scheduler) {
|
|
403
405
|
const schedulerPing = await this.scheduler.ping();
|
|
404
406
|
if (schedulerPing.status !== 'OK') {
|
|
405
|
-
return schedulerPing;
|
|
407
|
+
return { ...schedulerPing, paused: this.__isPaused };
|
|
406
408
|
}
|
|
407
409
|
}
|
|
408
|
-
|
|
410
|
+
const queuePing = await this.queue.ping();
|
|
411
|
+
return { ...queuePing, paused: this.__isPaused };
|
|
409
412
|
}
|
|
410
413
|
|
|
411
414
|
/**
|
|
@@ -423,7 +426,7 @@ class MailTime {
|
|
|
423
426
|
/**
|
|
424
427
|
* @memberOf MailTime
|
|
425
428
|
* @name destroy
|
|
426
|
-
* @description Stop the scheduler and block future dispatches. Pass `{ drain: true }` to
|
|
429
|
+
* @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, any in-flight SMTP attempts are neutralized on completion โ they perform no storage writes, no `onSent`/`onError` callbacks, and no logging once `destroy()` returns; their claimed rows are recovered by stale-lock timeout (`sendingTimeout`). Pass `{ drain: true }` to instead let in-flight attempts run to completion (returns a Promise that resolves once they settle).
|
|
427
430
|
* @param {{ drain?: boolean }} [opts]
|
|
428
431
|
* @returns {boolean | Promise<boolean>}
|
|
429
432
|
*/
|
|
@@ -434,12 +437,21 @@ class MailTime {
|
|
|
434
437
|
}
|
|
435
438
|
|
|
436
439
|
this.__isDestroyed = true;
|
|
440
|
+
this.__isPaused = false;
|
|
437
441
|
if (this.scheduler && typeof this.scheduler.destroy === 'function') {
|
|
438
442
|
this.scheduler.destroy();
|
|
439
443
|
}
|
|
440
444
|
if (opts && opts.drain === true && this.__pool) {
|
|
441
445
|
return this.__pool.drain().then(() => true);
|
|
442
446
|
}
|
|
447
|
+
// No graceful drain requested: neutralize in-flight SMTP completions so a
|
|
448
|
+
// destroyed instance performs no storage writes, no `onSent`/`onError`
|
|
449
|
+
// callbacks, and no logging after teardown. Each claimed row keeps its
|
|
450
|
+
// `isSending` lock, which a peer or a future instance recovers once
|
|
451
|
+
// `sendingTimeout` elapses โ identical to a crash mid-send. Use
|
|
452
|
+
// `destroy({ drain: true })` (or `await drain()` before `destroy()`) when
|
|
453
|
+
// in-flight sends must run to completion.
|
|
454
|
+
this.__abortInFlight = true;
|
|
443
455
|
return true;
|
|
444
456
|
}
|
|
445
457
|
|
|
@@ -457,6 +469,52 @@ class MailTime {
|
|
|
457
469
|
}
|
|
458
470
|
}
|
|
459
471
|
|
|
472
|
+
/**
|
|
473
|
+
* @memberOf MailTime
|
|
474
|
+
* @name pause
|
|
475
|
+
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
476
|
+
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
477
|
+
*/
|
|
478
|
+
pause() {
|
|
479
|
+
this.__debug('[pause]');
|
|
480
|
+
if (this.__isDestroyed || !this.scheduler) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
const paused = this.scheduler.pause();
|
|
484
|
+
if (paused) {
|
|
485
|
+
this.__isPaused = true;
|
|
486
|
+
}
|
|
487
|
+
return paused;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* @memberOf MailTime
|
|
492
|
+
* @name resume
|
|
493
|
+
* @description Resume competing for the queue-drain lease after `pause()`; triggers an immediate scan. No-op on `client` instances, after `destroy()`, or when not paused.
|
|
494
|
+
* @returns {boolean} `true` if newly resumed; `false` if not paused, a client instance, or destroyed
|
|
495
|
+
*/
|
|
496
|
+
resume() {
|
|
497
|
+
this.__debug('[resume]');
|
|
498
|
+
if (this.__isDestroyed || !this.scheduler) {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
const resumed = this.scheduler.resume();
|
|
502
|
+
if (resumed) {
|
|
503
|
+
this.__isPaused = false;
|
|
504
|
+
}
|
|
505
|
+
return resumed;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* @memberOf MailTime
|
|
510
|
+
* @name isPaused
|
|
511
|
+
* @description Whether this instance is currently paused from draining the queue. Always `false` on `client` instances.
|
|
512
|
+
* @returns {boolean}
|
|
513
|
+
*/
|
|
514
|
+
get isPaused() {
|
|
515
|
+
return this.__isPaused;
|
|
516
|
+
}
|
|
517
|
+
|
|
460
518
|
/**
|
|
461
519
|
* @memberOf MailTime
|
|
462
520
|
* @name send
|
|
@@ -574,6 +632,10 @@ class MailTime {
|
|
|
574
632
|
*/
|
|
575
633
|
async ___handleError(task, error, info) {
|
|
576
634
|
this.__debug('[private handleError]', { task, error, info });
|
|
635
|
+
if (this.__abortInFlight) {
|
|
636
|
+
this.__debug('[private handleError] instance destroyed; skipping onError', task?.uuid);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
577
639
|
if (!task) {
|
|
578
640
|
return;
|
|
579
641
|
}
|
|
@@ -687,7 +749,7 @@ class MailTime {
|
|
|
687
749
|
if (matchHTML) {
|
|
688
750
|
for (let i = 0; i < matchHTML.length; i++) {
|
|
689
751
|
const key = matchHTML[i].slice(3, -3).trim();
|
|
690
|
-
if (
|
|
752
|
+
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
691
753
|
string = string.replace(matchHTML[i], `${replacements[key]}`);
|
|
692
754
|
}
|
|
693
755
|
}
|
|
@@ -697,7 +759,7 @@ class MailTime {
|
|
|
697
759
|
if (matchStr) {
|
|
698
760
|
for (let i = 0; i < matchStr.length; i++) {
|
|
699
761
|
const key = matchStr[i].slice(2, -2).trim();
|
|
700
|
-
if (
|
|
762
|
+
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
701
763
|
string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
|
|
702
764
|
}
|
|
703
765
|
}
|
|
@@ -932,6 +994,11 @@ class MailTime {
|
|
|
932
994
|
|
|
933
995
|
await new Promise((resolve) => {
|
|
934
996
|
transport.sendMail(compiledOpts, async (error, info) => {
|
|
997
|
+
if (this.__abortInFlight) {
|
|
998
|
+
this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
|
|
999
|
+
resolve();
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
935
1002
|
this.__debug('[private send] [sending]', { error, info });
|
|
936
1003
|
try {
|
|
937
1004
|
if (error) {
|
|
@@ -1050,6 +1117,10 @@ class MailTime {
|
|
|
1050
1117
|
});
|
|
1051
1118
|
});
|
|
1052
1119
|
} catch (e) {
|
|
1120
|
+
if (this.__abortInFlight) {
|
|
1121
|
+
this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1053
1124
|
logError('Exception during runtime:', e);
|
|
1054
1125
|
await this.___handleError(task, e, {});
|
|
1055
1126
|
}
|
|
@@ -1064,7 +1135,7 @@ class MailTime {
|
|
|
1064
1135
|
*/
|
|
1065
1136
|
async ___iterate() {
|
|
1066
1137
|
this.__debug('[private iterate]');
|
|
1067
|
-
if (this.__isDestroyed) {
|
|
1138
|
+
if (this.__isDestroyed || this.__isPaused) {
|
|
1068
1139
|
return;
|
|
1069
1140
|
}
|
|
1070
1141
|
const limit = this.mode === 'one' ? 1 : Infinity;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mail-time",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "๐ฎ Email queue extending nodemailer with multi-SMTP transports and horizontally scaled apps support",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -47,6 +47,9 @@
|
|
|
47
47
|
"test": "npm run test:jest && npm run test:mocha && npm run test:types",
|
|
48
48
|
"test:jest": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.mjs",
|
|
49
49
|
"test:mocha": "mocha ./test/npm.js",
|
|
50
|
+
"test:mocha:redis": "mocha ./test/npm-redis-redis.js",
|
|
51
|
+
"test:mocha:mongo": "mocha ./test/npm-redis-mongo.js ./test/npm-mongo-redis.js ./test/npm-ha-mongo-redis.js",
|
|
52
|
+
"test:mocha:postgres": "mocha ./test/npm-postgres.js",
|
|
50
53
|
"test:types": "tsc -p ./tsconfig.types.json",
|
|
51
54
|
"test:redis": "mocha ./test/npm-redis-redis.js",
|
|
52
55
|
"test:mongo": "mocha ./test/npm-mongo-mongo.js",
|
|
@@ -110,10 +113,19 @@
|
|
|
110
113
|
"author": "dr.dimitru (https://veliovgroup.com)",
|
|
111
114
|
"license": "BSD-3-Clause",
|
|
112
115
|
"engines": {
|
|
113
|
-
"node": ">=20.9.0"
|
|
116
|
+
"node": ">=20.9.0",
|
|
117
|
+
"bun": ">=1.1.0"
|
|
118
|
+
},
|
|
119
|
+
"meteor": {
|
|
120
|
+
"packageName": "ostrio:mailer",
|
|
121
|
+
"versionsFrom": ["2.14", "3.2"],
|
|
122
|
+
"node": {
|
|
123
|
+
"2.14": ">=14",
|
|
124
|
+
"3.2": ">=18"
|
|
125
|
+
}
|
|
114
126
|
},
|
|
115
127
|
"dependencies": {
|
|
116
|
-
"josk": "^6.
|
|
128
|
+
"josk": "^6.2.0"
|
|
117
129
|
},
|
|
118
130
|
"devDependencies": {
|
|
119
131
|
"@jest/globals": "^30.4.1",
|
package/presets.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type MailTimePresetConfig = {
|
|
|
8
8
|
sendingTimeout?: number | undefined;
|
|
9
9
|
mode?: "one" | "batch" | undefined;
|
|
10
10
|
concurrency?: number | undefined;
|
|
11
|
-
onError?: ((error: unknown, email:
|
|
11
|
+
onError?: ((error: unknown, email: any, details?: object) => void) | undefined;
|
|
12
12
|
josk?: object | undefined;
|
|
13
13
|
};
|
|
14
14
|
export type MailTimePresetName = keyof typeof PRESETS;
|
|
@@ -31,11 +31,11 @@ export type MailTimePresetName = keyof typeof PRESETS;
|
|
|
31
31
|
*
|
|
32
32
|
* @param {MailTimePresetName} name - one of `presetNames`
|
|
33
33
|
* @param {object} [overrides] - additional MailTime constructor options
|
|
34
|
-
* @returns {
|
|
34
|
+
* @returns {MailTimePresetConfig} fresh, mutable MailTime constructor options (preset deep-cloned + overrides merged)
|
|
35
35
|
* @throws {Error} when `name` is unknown
|
|
36
36
|
* @throws {TypeError} when `overrides` is provided but not a plain object
|
|
37
37
|
*/
|
|
38
|
-
export function mailTimePreset(name: MailTimePresetName, overrides?: object):
|
|
38
|
+
export function mailTimePreset(name: MailTimePresetName, overrides?: object): MailTimePresetConfig;
|
|
39
39
|
/**
|
|
40
40
|
* Read-only map of preset name โ partial MailTime config. Use
|
|
41
41
|
* `mailTimePreset(name, overrides)` to materialize a merged copy; use
|
|
@@ -62,7 +62,7 @@ export const presetNames: ReadonlyArray<MailTimePresetName>;
|
|
|
62
62
|
* @property {number} [sendingTimeout]
|
|
63
63
|
* @property {'one' | 'batch'} [mode]
|
|
64
64
|
* @property {number} [concurrency]
|
|
65
|
-
* @property {(error: unknown, email:
|
|
65
|
+
* @property {(error: unknown, email: any, details?: object) => void} [onError]
|
|
66
66
|
* @property {object} [josk]
|
|
67
67
|
*/
|
|
68
68
|
/**
|
package/presets.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { deepMerge, isPlainObject, logError } from './helpers.js';
|
|
1
|
+
import { deepMerge, hasOwnProp, isPlainObject, logError } from './helpers.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Default `onError` hook used by every built-in preset. Logs via the
|
|
@@ -25,7 +25,7 @@ function defaultPresetOnError(error, email, info) {
|
|
|
25
25
|
* @property {number} [sendingTimeout]
|
|
26
26
|
* @property {'one' | 'batch'} [mode]
|
|
27
27
|
* @property {number} [concurrency]
|
|
28
|
-
* @property {(error: unknown, email:
|
|
28
|
+
* @property {(error: unknown, email: any, details?: object) => void} [onError]
|
|
29
29
|
* @property {object} [josk]
|
|
30
30
|
*/
|
|
31
31
|
|
|
@@ -175,12 +175,12 @@ const presetNames = Object.freeze(/** @type {MailTimePresetName[]} */ (Object.ke
|
|
|
175
175
|
*
|
|
176
176
|
* @param {MailTimePresetName} name - one of `presetNames`
|
|
177
177
|
* @param {object} [overrides] - additional MailTime constructor options
|
|
178
|
-
* @returns {
|
|
178
|
+
* @returns {MailTimePresetConfig} fresh, mutable MailTime constructor options (preset deep-cloned + overrides merged)
|
|
179
179
|
* @throws {Error} when `name` is unknown
|
|
180
180
|
* @throws {TypeError} when `overrides` is provided but not a plain object
|
|
181
181
|
*/
|
|
182
182
|
const mailTimePreset = (name, overrides) => {
|
|
183
|
-
if (typeof name !== 'string' || !
|
|
183
|
+
if (typeof name !== 'string' || !hasOwnProp(PRESETS, name)) {
|
|
184
184
|
throw new Error(`[mail-time] [mailTimePreset] unknown preset "${name}". Available: ${presetNames.join(', ')}`);
|
|
185
185
|
}
|
|
186
186
|
if (overrides !== void 0 && !isPlainObject(overrides)) {
|