mail-time 5.2.0 → 5.2.1

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
@@ -274,11 +274,11 @@ process.on('SIGTERM', async () => {
274
274
  });
275
275
  ```
276
276
 
277
- Graceful destroy waits for JoSk's running queue scan, then in-flight SMTP; claim renewal keeps running while sends drain. `schedulerTimeout` defaults to 10 seconds for JoSk's running-handler wait; it does not bound JoSk's own storage scan, SMTP, or policy hooks. A timed-out handler or a failed JoSk shutdown (logged) returns `false`; the Promise never rejects. `destroy()` without `{ drain: true }` stops immediately: in-flight completions make no storage writes and their claims recover after `sendingTimeout`. See [JoSk 6.4 recovery and shutdown](https://github.com/veliovgroup/mail-time/blob/master/docs/tuning.md#josk-64-restarts-and-shutdown).
277
+ Graceful destroy waits for JoSk's running queue scan, then in-flight SMTP; claim renewal keeps running while sends drain. Sends still waiting for a `concurrency` slot are dropped at once and their rows stay unclaimed for the next scan, so in-flight SMTP does not count against `schedulerTimeout`. `schedulerTimeout` defaults to 10 seconds for JoSk's running-handler wait; it does not bound JoSk's own storage scan, SMTP, or policy hooks. A timed-out handler or a failed JoSk shutdown (logged) returns `false`; the Promise never rejects. `destroy()` without `{ drain: true }` stops immediately: in-flight completions make no storage writes and their claims recover after `sendingTimeout`. See [JoSk 6.4 recovery and shutdown](https://github.com/veliovgroup/mail-time/blob/master/docs/tuning.md#josk-64-restarts-and-shutdown).
278
278
 
279
279
  #### Pause / resume a server (backpressure)
280
280
 
281
- `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
+ `pause()` stops this **server** instance from competing for the queue-drain lease without tearing it down (unlike `destroy()`). In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay queued; other `server` instances keep draining. `resume()` resumes and triggers an immediate scan.
282
282
 
283
283
  ```js
284
284
  mailQueue.pause(); // stop draining on this pod
@@ -558,7 +558,7 @@ For deeper JoSk semantics, install the JoSk skill: **`npx skills add veliovgroup
558
558
  - `cancel(uuid)` — alias of `cancelMail`.
559
559
  - `ping()` → `Promise<{status, code, statusCode, paused?, error?}>`. Pings scheduler then queue; `paused` reflects `isPaused`.
560
560
  - `ready()` → `Promise<MailTime>`. Awaits all startup work; rejects with `.cause` on storage failure.
561
- - `destroy(opts?)` → `boolean` or `Promise<boolean>` when `{ drain: true }`. Graceful shutdown waits for JoSk's scan and the SMTP pool; `schedulerTimeout` defaults to 10000 ms; a timed-out scan or failed JoSk shutdown resolves `false` (never rejects). Plain `destroy()` aborts completion writes.
561
+ - `destroy(opts?)` → `boolean` or `Promise<boolean>` when `{ drain: true }`. Graceful shutdown waits for JoSk's scan and the SMTP pool; sends not yet started are dropped and stay queued; `schedulerTimeout` defaults to 10000 ms; a timed-out scan or failed JoSk shutdown resolves `false` (never rejects). Plain `destroy()` aborts completion writes.
562
562
  - `drain()` → `Promise<void>`. Resolves once every in-flight SMTP attempt finishes. Useful in tests and graceful-shutdown paths.
563
563
  - `pause()` / `resume()` → `boolean`. Server-only reversible backpressure; no-ops on `client` or after `destroy()`. See [Shutdown](#6-shutdown).
564
564
  - `isPaused` → `boolean`. Read-only; always `false` on `client`.
package/adapters/mongo.js CHANGED
@@ -234,7 +234,7 @@ class MongoQueue {
234
234
  }
235
235
 
236
236
  try {
237
- while (await cursor.hasNext()) {
237
+ while (!this.mailTimeInstance.___isStopped && await cursor.hasNext()) {
238
238
  await this.mailTimeInstance.___dispatch(await cursor.next());
239
239
  }
240
240
  } finally {
@@ -273,6 +273,9 @@ class PostgresQueue {
273
273
  LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
274
274
 
275
275
  for (const row of res.rows || []) {
276
+ if (this.mailTimeInstance.___isStopped) {
277
+ break;
278
+ }
276
279
  await this.mailTimeInstance.___dispatch(normalizeRow(row));
277
280
  }
278
281
  } catch (iterateError) {
package/adapters/redis.js CHANGED
@@ -485,6 +485,9 @@ class RedisQueue {
485
485
  return;
486
486
  }
487
487
  for (const value of candidates) {
488
+ if (this.mailTimeInstance.___isStopped) {
489
+ break;
490
+ }
488
491
  const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
489
492
  if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
490
493
  await this.mailTimeInstance.___dispatch(candidate);
@@ -505,6 +508,9 @@ class RedisQueue {
505
508
  for await (const cursorValue of cursor) {
506
509
  const sendatKeys = Array.isArray(cursorValue) ? cursorValue : [cursorValue];
507
510
  for (const sendatKey of sendatKeys) {
511
+ if (this.mailTimeInstance.___isStopped) {
512
+ break outer;
513
+ }
508
514
  const raw = await this.client.get(sendatKey);
509
515
  if (raw === null || parseInt(raw, 10) > now) {
510
516
  continue;
package/index.cjs CHANGED
@@ -524,7 +524,7 @@ class MongoQueue {
524
524
  }
525
525
 
526
526
  try {
527
- while (await cursor.hasNext()) {
527
+ while (!this.mailTimeInstance.___isStopped && await cursor.hasNext()) {
528
528
  await this.mailTimeInstance.___dispatch(await cursor.next());
529
529
  }
530
530
  } finally {
@@ -1214,6 +1214,9 @@ class RedisQueue {
1214
1214
  return;
1215
1215
  }
1216
1216
  for (const value of candidates) {
1217
+ if (this.mailTimeInstance.___isStopped) {
1218
+ break;
1219
+ }
1217
1220
  const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
1218
1221
  if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
1219
1222
  await this.mailTimeInstance.___dispatch(candidate);
@@ -1234,6 +1237,9 @@ class RedisQueue {
1234
1237
  for await (const cursorValue of cursor) {
1235
1238
  const sendatKeys = Array.isArray(cursorValue) ? cursorValue : [cursorValue];
1236
1239
  for (const sendatKey of sendatKeys) {
1240
+ if (this.mailTimeInstance.___isStopped) {
1241
+ break outer;
1242
+ }
1237
1243
  const raw = await this.client.get(sendatKey);
1238
1244
  if (raw === null || parseInt(raw, 10) > now) {
1239
1245
  continue;
@@ -1964,6 +1970,9 @@ class PostgresQueue {
1964
1970
  LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
1965
1971
 
1966
1972
  for (const row of res.rows || []) {
1973
+ if (this.mailTimeInstance.___isStopped) {
1974
+ break;
1975
+ }
1967
1976
  await this.mailTimeInstance.___dispatch(normalizeRow(row));
1968
1977
  }
1969
1978
  } catch (iterateError) {
@@ -2305,12 +2314,12 @@ const PRESETS = Object.freeze({
2305
2314
  transactional: Object.freeze({
2306
2315
  concatEmails: false,
2307
2316
  retries: 30,
2308
- retryDelay: 10_000,
2317
+ retryDelay: 10000,
2309
2318
  mode: 'batch',
2310
2319
  concurrency: 1,
2311
2320
  onError: defaultPresetOnError,
2312
2321
  josk: Object.freeze({
2313
- zombieTime: 120_000,
2322
+ zombieTime: 120000,
2314
2323
  }),
2315
2324
  }),
2316
2325
  otp: Object.freeze({
@@ -2318,52 +2327,52 @@ const PRESETS = Object.freeze({
2318
2327
  retries: 5,
2319
2328
  retryDelay: 2000,
2320
2329
  revolvingInterval: 1024,
2321
- sendingTimeout: 120_000,
2330
+ sendingTimeout: 120000,
2322
2331
  mode: 'batch',
2323
2332
  concurrency: 4,
2324
2333
  onError: defaultPresetOnError,
2325
2334
  josk: Object.freeze({
2326
2335
  minRevolvingDelay: 256,
2327
2336
  maxRevolvingDelay: 1024,
2328
- zombieTime: 60_000,
2337
+ zombieTime: 60000,
2329
2338
  }),
2330
2339
  }),
2331
2340
  newsletter: Object.freeze({
2332
2341
  concatEmails: true,
2333
- concatDelay: 5 * 60_000,
2342
+ concatDelay: 5 * 60000,
2334
2343
  concatSubject: 'Your updates',
2335
2344
  retries: 5,
2336
- retryDelay: 60_000,
2337
- sendingTimeout: 600_000,
2345
+ retryDelay: 60000,
2346
+ sendingTimeout: 600000,
2338
2347
  mode: 'batch',
2339
2348
  concurrency: 2,
2340
2349
  onError: defaultPresetOnError,
2341
2350
  josk: Object.freeze({
2342
- zombieTime: 300_000,
2351
+ zombieTime: 300000,
2343
2352
  }),
2344
2353
  }),
2345
2354
  marketing: Object.freeze({
2346
2355
  concatEmails: false,
2347
2356
  retries: 10,
2348
- retryDelay: 30_000,
2357
+ retryDelay: 30000,
2349
2358
  mode: 'batch',
2350
2359
  concurrency: 5,
2351
2360
  onError: defaultPresetOnError,
2352
2361
  josk: Object.freeze({
2353
- zombieTime: 180_000,
2362
+ zombieTime: 180000,
2354
2363
  }),
2355
2364
  }),
2356
2365
  notifications: Object.freeze({
2357
2366
  concatEmails: true,
2358
- concatDelay: 60_000,
2367
+ concatDelay: 60000,
2359
2368
  concatSubject: 'New activity',
2360
2369
  retries: 8,
2361
- retryDelay: 30_000,
2370
+ retryDelay: 30000,
2362
2371
  mode: 'batch',
2363
2372
  concurrency: 3,
2364
2373
  onError: defaultPresetOnError,
2365
2374
  josk: Object.freeze({
2366
- zombieTime: 180_000,
2375
+ zombieTime: 180000,
2367
2376
  }),
2368
2377
  }),
2369
2378
  alerts: Object.freeze({
@@ -2371,14 +2380,14 @@ const PRESETS = Object.freeze({
2371
2380
  retries: 20,
2372
2381
  retryDelay: 5000,
2373
2382
  revolvingInterval: 1024,
2374
- sendingTimeout: 120_000,
2383
+ sendingTimeout: 120000,
2375
2384
  mode: 'batch',
2376
2385
  concurrency: 2,
2377
2386
  onError: defaultPresetOnError,
2378
2387
  josk: Object.freeze({
2379
2388
  minRevolvingDelay: 256,
2380
2389
  maxRevolvingDelay: 1024,
2381
- zombieTime: 60_000,
2390
+ zombieTime: 60000,
2382
2391
  }),
2383
2392
  }),
2384
2393
  });
@@ -2813,11 +2822,19 @@ const createPool = (concurrency) => {
2813
2822
  let active = 0;
2814
2823
  let drainResolvers = [];
2815
2824
 
2825
+ const settleDrain = () => {
2826
+ if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
2827
+ const resolvers = drainResolvers;
2828
+ drainResolvers = [];
2829
+ for (const r of resolvers) r();
2830
+ }
2831
+ };
2832
+
2816
2833
  const tryStart = () => {
2817
2834
  while (active < limit && queue.length > 0) {
2818
2835
  const job = queue.shift();
2819
2836
  active++;
2820
- job.resolveSlot();
2837
+ job.resolveSlot(true);
2821
2838
  Promise.resolve()
2822
2839
  .then(job.fn)
2823
2840
  .catch((poolError) => {
@@ -2825,11 +2842,7 @@ const createPool = (concurrency) => {
2825
2842
  })
2826
2843
  .finally(() => {
2827
2844
  active--;
2828
- if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
2829
- const resolvers = drainResolvers;
2830
- drainResolvers = [];
2831
- for (const r of resolvers) r();
2832
- }
2845
+ settleDrain();
2833
2846
  tryStart();
2834
2847
  });
2835
2848
  }
@@ -2838,10 +2851,11 @@ const createPool = (concurrency) => {
2838
2851
  return {
2839
2852
  /**
2840
2853
  * Queue `fn` for execution under the concurrency limit.
2841
- * The returned Promise resolves as soon as a slot is acquired and `fn` has started.
2854
+ * The returned Promise resolves `true` as soon as a slot is acquired and `fn` has started,
2855
+ * or `false` if `cancelQueued()` dropped the job before it started (`fn` never runs).
2842
2856
  * It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
2843
2857
  * @param {() => Promise<void>} fn
2844
- * @returns {Promise<void>}
2858
+ * @returns {Promise<boolean>}
2845
2859
  */
2846
2860
  dispatch(fn) {
2847
2861
  return new Promise((resolveSlot) => {
@@ -2849,6 +2863,16 @@ const createPool = (concurrency) => {
2849
2863
  tryStart();
2850
2864
  });
2851
2865
  },
2866
+ /**
2867
+ * Drop every job still waiting for a slot. Running jobs are untouched.
2868
+ * @returns {number} dropped jobs
2869
+ */
2870
+ cancelQueued() {
2871
+ const dropped = queue.splice(0);
2872
+ for (const job of dropped) job.resolveSlot(false);
2873
+ settleDrain();
2874
+ return dropped.length;
2875
+ },
2852
2876
  drain() {
2853
2877
  if (active === 0 && queue.length === 0) {
2854
2878
  return Promise.resolve();
@@ -3100,6 +3124,7 @@ class MailTime {
3100
3124
  this.__isDestroyed = false;
3101
3125
  this.__abortInFlight = false;
3102
3126
  this.__isPaused = false;
3127
+ this.__schedulerScans = 0;
3103
3128
  this.__readyPromise = null;
3104
3129
  this.__schedulerTimer = null;
3105
3130
  this.__inFlight = new Set();
@@ -3277,7 +3302,7 @@ class MailTime {
3277
3302
  /**
3278
3303
  * @memberOf MailTime
3279
3304
  * @name destroy
3280
- * @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if a scheduler handler exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. The timeout does not bound SMTP drain time.
3305
+ * @description Stop the scheduler and block future dispatches. Sends still waiting for a `concurrency` slot are dropped at once; their rows stay unclaimed for the next scan. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if the queue scan exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. In-flight SMTP does not count against the timeout, and the timeout does not bound SMTP drain time.
3281
3306
  * @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
3282
3307
  * @returns {boolean | Promise<boolean>}
3283
3308
  */
@@ -3293,6 +3318,9 @@ class MailTime {
3293
3318
 
3294
3319
  this.__isDestroyed = true;
3295
3320
  this.__isPaused = false;
3321
+ // Queued jobs never started SMTP; dropping them lets a scan blocked on a
3322
+ // pool slot return, so JoSk shutdown does not wait for in-flight sends.
3323
+ this.__pool.cancelQueued();
3296
3324
  if (opts?.drain === true) {
3297
3325
  return (async () => {
3298
3326
  let finished = true;
@@ -3338,7 +3366,7 @@ class MailTime {
3338
3366
  /**
3339
3367
  * @memberOf MailTime
3340
3368
  * @name pause
3341
- * @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();`.
3369
+ * @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay unclaimed; 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();`.
3342
3370
  * @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
3343
3371
  */
3344
3372
  pause() {
@@ -3349,6 +3377,9 @@ class MailTime {
3349
3377
  const paused = this.scheduler.pause();
3350
3378
  if (paused) {
3351
3379
  this.__isPaused = true;
3380
+ if (this.__schedulerScans > 0) {
3381
+ this.__pool.cancelQueued();
3382
+ }
3352
3383
  }
3353
3384
  return paused;
3354
3385
  }
@@ -3674,9 +3705,11 @@ class MailTime {
3674
3705
  compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
3675
3706
  }
3676
3707
 
3677
- compiledOpts.html ??= '';
3678
- compiledOpts.text ??= '';
3679
- compiledOpts.subject ??= '';
3708
+ for (const field of ['html', 'text', 'subject']) {
3709
+ if (compiledOpts[field] === void 0 || compiledOpts[field] === null) {
3710
+ compiledOpts[field] = '';
3711
+ }
3712
+ }
3680
3713
 
3681
3714
  const mailOptionsList = task.mailOptions || [];
3682
3715
  const isMulti = mailOptionsList.length > 1;
@@ -4193,12 +4226,12 @@ class MailTime {
4193
4226
  * @internal
4194
4227
  * @memberOf MailTime
4195
4228
  * @name ___dispatch
4196
- * @description Queue full-lifecycle send for `task` under the bounded send pool. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
4229
+ * @description Queue full-lifecycle send for `task` under the bounded send pool. No-op after `destroy()`, or while paused during a scheduler-driven scan; `destroy()` and `pause()` also drop jobs still waiting for a slot. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
4197
4230
  * @param {MailTimeTask} task - email's task object from Storage
4198
4231
  * @returns {Promise<void 0>}
4199
4232
  */
4200
4233
  async ___dispatch(task) {
4201
- if (this.__isDestroyed) {
4234
+ if (this.___isStopped) {
4202
4235
  return;
4203
4236
  }
4204
4237
  if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
@@ -4209,9 +4242,9 @@ class MailTime {
4209
4242
  return;
4210
4243
  }
4211
4244
  this.__inFlight.add(task.uuid);
4212
- await this.__pool.dispatch(async () => {
4245
+ const started = await this.__pool.dispatch(async () => {
4213
4246
  try {
4214
- if (this.__isDestroyed) {
4247
+ if (this.___isStopped) {
4215
4248
  return;
4216
4249
  }
4217
4250
  await this.___send(task);
@@ -4219,6 +4252,20 @@ class MailTime {
4219
4252
  this.__inFlight.delete(task.uuid);
4220
4253
  }
4221
4254
  });
4255
+ if (!started) {
4256
+ this.__inFlight.delete(task.uuid);
4257
+ }
4258
+ }
4259
+
4260
+ /**
4261
+ * @internal
4262
+ * @memberOf MailTime
4263
+ * @name ___isStopped
4264
+ * @description `true` after `destroy()`, or while paused during a scheduler-driven scan. Queue adapters check it to end an `iterate` scan early.
4265
+ * @returns {boolean}
4266
+ */
4267
+ get ___isStopped() {
4268
+ return this.__isDestroyed || (this.__isPaused && this.__schedulerScans > 0);
4222
4269
  }
4223
4270
 
4224
4271
  /**
@@ -4433,10 +4480,15 @@ class MailTime {
4433
4480
  return;
4434
4481
  }
4435
4482
  const limit = this.mode === 'one' ? 1 : Infinity;
4436
- return await this.queue.iterate({
4437
- limit,
4438
- sendingTimeout: this.sendingTimeout,
4439
- });
4483
+ this.__schedulerScans++;
4484
+ try {
4485
+ return await this.queue.iterate({
4486
+ limit,
4487
+ sendingTimeout: this.sendingTimeout,
4488
+ });
4489
+ } finally {
4490
+ this.__schedulerScans--;
4491
+ }
4440
4492
  }
4441
4493
 
4442
4494
  /**
package/index.d.cts CHANGED
@@ -404,7 +404,7 @@ export class MailTime {
404
404
  /**
405
405
  * @memberOf MailTime
406
406
  * @name destroy
407
- * @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if a scheduler handler exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. The timeout does not bound SMTP drain time.
407
+ * @description Stop the scheduler and block future dispatches. Sends still waiting for a `concurrency` slot are dropped at once; their rows stay unclaimed for the next scan. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if the queue scan exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. In-flight SMTP does not count against the timeout, and the timeout does not bound SMTP drain time.
408
408
  * @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
409
409
  * @returns {boolean | Promise<boolean>}
410
410
  */
@@ -423,7 +423,7 @@ export class MailTime {
423
423
  /**
424
424
  * @memberOf MailTime
425
425
  * @name pause
426
- * @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();`.
426
+ * @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay unclaimed; 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();`.
427
427
  * @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
428
428
  */
429
429
  pause(): boolean;
package/index.d.ts CHANGED
@@ -404,7 +404,7 @@ export class MailTime {
404
404
  /**
405
405
  * @memberOf MailTime
406
406
  * @name destroy
407
- * @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if a scheduler handler exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. The timeout does not bound SMTP drain time.
407
+ * @description Stop the scheduler and block future dispatches. Sends still waiting for a `concurrency` slot are dropped at once; their rows stay unclaimed for the next scan. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if the queue scan exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. In-flight SMTP does not count against the timeout, and the timeout does not bound SMTP drain time.
408
408
  * @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
409
409
  * @returns {boolean | Promise<boolean>}
410
410
  */
@@ -423,7 +423,7 @@ export class MailTime {
423
423
  /**
424
424
  * @memberOf MailTime
425
425
  * @name pause
426
- * @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();`.
426
+ * @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay unclaimed; 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();`.
427
427
  * @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
428
428
  */
429
429
  pause(): boolean;
package/index.js CHANGED
@@ -79,11 +79,19 @@ const createPool = (concurrency) => {
79
79
  let active = 0;
80
80
  let drainResolvers = [];
81
81
 
82
+ const settleDrain = () => {
83
+ if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
84
+ const resolvers = drainResolvers;
85
+ drainResolvers = [];
86
+ for (const r of resolvers) r();
87
+ }
88
+ };
89
+
82
90
  const tryStart = () => {
83
91
  while (active < limit && queue.length > 0) {
84
92
  const job = queue.shift();
85
93
  active++;
86
- job.resolveSlot();
94
+ job.resolveSlot(true);
87
95
  Promise.resolve()
88
96
  .then(job.fn)
89
97
  .catch((poolError) => {
@@ -91,11 +99,7 @@ const createPool = (concurrency) => {
91
99
  })
92
100
  .finally(() => {
93
101
  active--;
94
- if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
95
- const resolvers = drainResolvers;
96
- drainResolvers = [];
97
- for (const r of resolvers) r();
98
- }
102
+ settleDrain();
99
103
  tryStart();
100
104
  });
101
105
  }
@@ -104,10 +108,11 @@ const createPool = (concurrency) => {
104
108
  return {
105
109
  /**
106
110
  * Queue `fn` for execution under the concurrency limit.
107
- * The returned Promise resolves as soon as a slot is acquired and `fn` has started.
111
+ * The returned Promise resolves `true` as soon as a slot is acquired and `fn` has started,
112
+ * or `false` if `cancelQueued()` dropped the job before it started (`fn` never runs).
108
113
  * It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
109
114
  * @param {() => Promise<void>} fn
110
- * @returns {Promise<void>}
115
+ * @returns {Promise<boolean>}
111
116
  */
112
117
  dispatch(fn) {
113
118
  return new Promise((resolveSlot) => {
@@ -115,6 +120,16 @@ const createPool = (concurrency) => {
115
120
  tryStart();
116
121
  });
117
122
  },
123
+ /**
124
+ * Drop every job still waiting for a slot. Running jobs are untouched.
125
+ * @returns {number} dropped jobs
126
+ */
127
+ cancelQueued() {
128
+ const dropped = queue.splice(0);
129
+ for (const job of dropped) job.resolveSlot(false);
130
+ settleDrain();
131
+ return dropped.length;
132
+ },
118
133
  drain() {
119
134
  if (active === 0 && queue.length === 0) {
120
135
  return Promise.resolve();
@@ -366,6 +381,7 @@ class MailTime {
366
381
  this.__isDestroyed = false;
367
382
  this.__abortInFlight = false;
368
383
  this.__isPaused = false;
384
+ this.__schedulerScans = 0;
369
385
  this.__readyPromise = null;
370
386
  this.__schedulerTimer = null;
371
387
  this.__inFlight = new Set();
@@ -543,7 +559,7 @@ class MailTime {
543
559
  /**
544
560
  * @memberOf MailTime
545
561
  * @name destroy
546
- * @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if a scheduler handler exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. The timeout does not bound SMTP drain time.
562
+ * @description Stop the scheduler and block future dispatches. Sends still waiting for a `concurrency` slot are dropped at once; their rows stay unclaimed for the next scan. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if the queue scan exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. In-flight SMTP does not count against the timeout, and the timeout does not bound SMTP drain time.
547
563
  * @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
548
564
  * @returns {boolean | Promise<boolean>}
549
565
  */
@@ -559,6 +575,9 @@ class MailTime {
559
575
 
560
576
  this.__isDestroyed = true;
561
577
  this.__isPaused = false;
578
+ // Queued jobs never started SMTP; dropping them lets a scan blocked on a
579
+ // pool slot return, so JoSk shutdown does not wait for in-flight sends.
580
+ this.__pool.cancelQueued();
562
581
  if (opts?.drain === true) {
563
582
  return (async () => {
564
583
  let finished = true;
@@ -604,7 +623,7 @@ class MailTime {
604
623
  /**
605
624
  * @memberOf MailTime
606
625
  * @name pause
607
- * @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();`.
626
+ * @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay unclaimed; 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();`.
608
627
  * @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
609
628
  */
610
629
  pause() {
@@ -615,6 +634,9 @@ class MailTime {
615
634
  const paused = this.scheduler.pause();
616
635
  if (paused) {
617
636
  this.__isPaused = true;
637
+ if (this.__schedulerScans > 0) {
638
+ this.__pool.cancelQueued();
639
+ }
618
640
  }
619
641
  return paused;
620
642
  }
@@ -940,9 +962,11 @@ class MailTime {
940
962
  compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
941
963
  }
942
964
 
943
- compiledOpts.html ??= '';
944
- compiledOpts.text ??= '';
945
- compiledOpts.subject ??= '';
965
+ for (const field of ['html', 'text', 'subject']) {
966
+ if (compiledOpts[field] === void 0 || compiledOpts[field] === null) {
967
+ compiledOpts[field] = '';
968
+ }
969
+ }
946
970
 
947
971
  const mailOptionsList = task.mailOptions || [];
948
972
  const isMulti = mailOptionsList.length > 1;
@@ -1459,12 +1483,12 @@ class MailTime {
1459
1483
  * @internal
1460
1484
  * @memberOf MailTime
1461
1485
  * @name ___dispatch
1462
- * @description Queue full-lifecycle send for `task` under the bounded send pool. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
1486
+ * @description Queue full-lifecycle send for `task` under the bounded send pool. No-op after `destroy()`, or while paused during a scheduler-driven scan; `destroy()` and `pause()` also drop jobs still waiting for a slot. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
1463
1487
  * @param {MailTimeTask} task - email's task object from Storage
1464
1488
  * @returns {Promise<void 0>}
1465
1489
  */
1466
1490
  async ___dispatch(task) {
1467
- if (this.__isDestroyed) {
1491
+ if (this.___isStopped) {
1468
1492
  return;
1469
1493
  }
1470
1494
  if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
@@ -1475,9 +1499,9 @@ class MailTime {
1475
1499
  return;
1476
1500
  }
1477
1501
  this.__inFlight.add(task.uuid);
1478
- await this.__pool.dispatch(async () => {
1502
+ const started = await this.__pool.dispatch(async () => {
1479
1503
  try {
1480
- if (this.__isDestroyed) {
1504
+ if (this.___isStopped) {
1481
1505
  return;
1482
1506
  }
1483
1507
  await this.___send(task);
@@ -1485,6 +1509,20 @@ class MailTime {
1485
1509
  this.__inFlight.delete(task.uuid);
1486
1510
  }
1487
1511
  });
1512
+ if (!started) {
1513
+ this.__inFlight.delete(task.uuid);
1514
+ }
1515
+ }
1516
+
1517
+ /**
1518
+ * @internal
1519
+ * @memberOf MailTime
1520
+ * @name ___isStopped
1521
+ * @description `true` after `destroy()`, or while paused during a scheduler-driven scan. Queue adapters check it to end an `iterate` scan early.
1522
+ * @returns {boolean}
1523
+ */
1524
+ get ___isStopped() {
1525
+ return this.__isDestroyed || (this.__isPaused && this.__schedulerScans > 0);
1488
1526
  }
1489
1527
 
1490
1528
  /**
@@ -1699,10 +1737,15 @@ class MailTime {
1699
1737
  return;
1700
1738
  }
1701
1739
  const limit = this.mode === 'one' ? 1 : Infinity;
1702
- return await this.queue.iterate({
1703
- limit,
1704
- sendingTimeout: this.sendingTimeout,
1705
- });
1740
+ this.__schedulerScans++;
1741
+ try {
1742
+ return await this.queue.iterate({
1743
+ limit,
1744
+ sendingTimeout: this.sendingTimeout,
1745
+ });
1746
+ } finally {
1747
+ this.__schedulerScans--;
1748
+ }
1706
1749
  }
1707
1750
 
1708
1751
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mail-time",
3
- "version": "5.2.0",
3
+ "version": "5.2.1",
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",
@@ -154,6 +154,7 @@
154
154
  "devDependencies": {
155
155
  "@jest/globals": "^30.4.1",
156
156
  "@types/node": "^25.9.0",
157
+ "acorn": "^8.18.0",
157
158
  "chai": "^6.2.2",
158
159
  "jest": "^30.4.2",
159
160
  "mocha": "^11.8.0",
package/presets.js CHANGED
@@ -60,12 +60,12 @@ const PRESETS = Object.freeze({
60
60
  transactional: Object.freeze({
61
61
  concatEmails: false,
62
62
  retries: 30,
63
- retryDelay: 10_000,
63
+ retryDelay: 10000,
64
64
  mode: 'batch',
65
65
  concurrency: 1,
66
66
  onError: defaultPresetOnError,
67
67
  josk: Object.freeze({
68
- zombieTime: 120_000,
68
+ zombieTime: 120000,
69
69
  }),
70
70
  }),
71
71
  otp: Object.freeze({
@@ -73,52 +73,52 @@ const PRESETS = Object.freeze({
73
73
  retries: 5,
74
74
  retryDelay: 2000,
75
75
  revolvingInterval: 1024,
76
- sendingTimeout: 120_000,
76
+ sendingTimeout: 120000,
77
77
  mode: 'batch',
78
78
  concurrency: 4,
79
79
  onError: defaultPresetOnError,
80
80
  josk: Object.freeze({
81
81
  minRevolvingDelay: 256,
82
82
  maxRevolvingDelay: 1024,
83
- zombieTime: 60_000,
83
+ zombieTime: 60000,
84
84
  }),
85
85
  }),
86
86
  newsletter: Object.freeze({
87
87
  concatEmails: true,
88
- concatDelay: 5 * 60_000,
88
+ concatDelay: 5 * 60000,
89
89
  concatSubject: 'Your updates',
90
90
  retries: 5,
91
- retryDelay: 60_000,
92
- sendingTimeout: 600_000,
91
+ retryDelay: 60000,
92
+ sendingTimeout: 600000,
93
93
  mode: 'batch',
94
94
  concurrency: 2,
95
95
  onError: defaultPresetOnError,
96
96
  josk: Object.freeze({
97
- zombieTime: 300_000,
97
+ zombieTime: 300000,
98
98
  }),
99
99
  }),
100
100
  marketing: Object.freeze({
101
101
  concatEmails: false,
102
102
  retries: 10,
103
- retryDelay: 30_000,
103
+ retryDelay: 30000,
104
104
  mode: 'batch',
105
105
  concurrency: 5,
106
106
  onError: defaultPresetOnError,
107
107
  josk: Object.freeze({
108
- zombieTime: 180_000,
108
+ zombieTime: 180000,
109
109
  }),
110
110
  }),
111
111
  notifications: Object.freeze({
112
112
  concatEmails: true,
113
- concatDelay: 60_000,
113
+ concatDelay: 60000,
114
114
  concatSubject: 'New activity',
115
115
  retries: 8,
116
- retryDelay: 30_000,
116
+ retryDelay: 30000,
117
117
  mode: 'batch',
118
118
  concurrency: 3,
119
119
  onError: defaultPresetOnError,
120
120
  josk: Object.freeze({
121
- zombieTime: 180_000,
121
+ zombieTime: 180000,
122
122
  }),
123
123
  }),
124
124
  alerts: Object.freeze({
@@ -126,14 +126,14 @@ const PRESETS = Object.freeze({
126
126
  retries: 20,
127
127
  retryDelay: 5000,
128
128
  revolvingInterval: 1024,
129
- sendingTimeout: 120_000,
129
+ sendingTimeout: 120000,
130
130
  mode: 'batch',
131
131
  concurrency: 2,
132
132
  onError: defaultPresetOnError,
133
133
  josk: Object.freeze({
134
134
  minRevolvingDelay: 256,
135
135
  maxRevolvingDelay: 1024,
136
- zombieTime: 60_000,
136
+ zombieTime: 60000,
137
137
  }),
138
138
  }),
139
139
  });