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 CHANGED
@@ -1,11 +1,19 @@
1
1
 
2
2
  # MailTime
3
3
 
4
- [![npm version](https://img.shields.io/npm/v/mail-time.svg?label=mail-time)](https://www.npmjs.com/package/mail-time)
5
- [![CI](https://img.shields.io/github/actions/workflow/status/veliovgroup/mail-time/ci.yml?branch=master)](https://github.com/veliovgroup/mail-time/actions)
6
- [![license](https://img.shields.io/npm/l/mail-time.svg)](LICENSE)
7
- [![GitHub Sponsors](https://img.shields.io/github/sponsors/dr-dimitru?label=Sponsor)](https://github.com/sponsors/dr-dimitru)
8
- [![Donate](https://img.shields.io/badge/Donate-PayPal-00457C?logo=paypal&logoColor=white)](https://paypal.me/veliovgroup)
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
- - ๐Ÿงช **95%+ Jest coverage** (85% threshold enforced) + Mocha integration tests for every adapter.
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 from v3? See [Migration from 3.x](#migration-from-3x) and the full [v4.0.0 release notes](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v3-v4.md).
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 into the current project:
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
- ## Migration from 3.x
506
+ ## Migrations
472
507
 
473
- Full v4 highlights, adapter changes, and type exports live in [docs/migration-v3-v4.md](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v3-v4.md). Quick checklist:
508
+ Upgrade checklists, adapter contract changes, and rollout notes live in the docs (no duplication here):
474
509
 
475
- 1. **Node โ‰ฅ 20.9.0**, Bun โ‰ฅ 1.1.0. Bump your runtime first.
476
- 2. **Swap adapter imports** to the new `MongoQueue` / `RedisQueue` / `PostgresQueue` constructors.
477
- 3. **Pass `josk`** โ€” it's now required for `type: 'server'`.
478
- 4. **`josk.zombieTime` default raised to `60000` ms** (was `32786`). Set it explicitly if you relied on the old value.
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 surface to opt into: [`mailTimePreset`](#settings-presets), `concurrency`, `mode`, `sendingTimeout`, `drain()`, per-recipient retries, and the [AI agent skills](#ai-agent-skills) bundle.
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
- while (await cursor.hasNext()) {
232
- await this.mailTimeInstance.___dispatch(await cursor.next());
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
  }
@@ -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
- const setupLockId = 93824519;
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
- await this.client.query('SELECT pg_advisory_lock($1)', [setupLockId]);
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)', [setupLockId]);
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
- * Returns the address lowercased, or `null` when none can be parsed.
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. Returns `void 0` when the filtered
155
- * array would be empty or the single string is dropped.
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
- /** Strip MailTime-internal update keys before persisting to storage. */
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 node_crypto = require('node:crypto');
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
- * Returns the address lowercased, or `null` when none can be parsed.
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. Returns `void 0` when the filtered
160
- * array would be empty or the single string is dropped.
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
- /** Strip MailTime-internal update keys before persisting to storage. */
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
- while (await cursor.hasNext()) {
429
- await this.mailTimeInstance.___dispatch(await cursor.next());
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
- const setupLockId = 93824519;
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
- await this.client.query('SELECT pg_advisory_lock($1)', [setupLockId]);
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)', [setupLockId]);
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: object, details?: object) => void} [onError]
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 {object} fresh, mutable MailTime constructor options
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' || !Object.hasOwn(PRESETS, name)) {
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
- return await this.queue.ping();
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 also wait for in-flight SMTP attempts (returns a Promise).
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: node_crypto.randomUUID(),
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 (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
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 (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
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 also wait for in-flight SMTP attempts (returns a Promise).
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 also wait for in-flight SMTP attempts (returns a Promise).
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 'node:crypto';
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
- return await this.queue.ping();
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 also wait for in-flight SMTP attempts (returns a Promise).
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 (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
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 (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
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.0.0",
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.1.0"
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: object, details?: object) => void) | undefined;
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 {object} fresh, mutable MailTime constructor options
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): 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: object, details?: object) => void} [onError]
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: object, details?: object) => void} [onError]
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 {object} fresh, mutable MailTime constructor options
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' || !Object.hasOwn(PRESETS, name)) {
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)) {