mail-time 5.1.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
@@ -1,6 +1,3 @@
1
-
2
- # MailTime
3
-
4
1
  [![npm version][badge-npm-v]][npm-url]
5
2
  [![npm downloads][badge-npm-dm]][npm-url]
6
3
  [![CI][badge-ci]][ci-url]
@@ -14,6 +11,11 @@
14
11
  [![dependencies][badge-deps]][deps-url]
15
12
  [![Sponsor][badge-sponsor]][sponsor-url]
16
13
  [![Donate][badge-donate]][donate-url]
14
+ <a href="https://bridge-cdn.com/?ref=github-mail-time-repo-top"><img src="https://bridge-cdn.com/favicon.svg" height="20"></a>
15
+ <a href="https://ostr.io/info/built-by-developers-for-developers?ref=github-mail-time-repo-top"><img src="https://ostr.io/apple-touch-icon-60x60.png" height="20"></a>
16
+ <a href="https://meteor-files.com/?ref=github-mail-time-repo-top"><img src="https://meteor-files.com/apple-touch-icon-60x60.png" height="20"></a>
17
+
18
+ # MailTime
17
19
 
18
20
  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.
19
21
 
@@ -267,15 +269,16 @@ export const mailQueue = new MailTime({
267
269
 
268
270
  ```js
269
271
  process.on('SIGTERM', async () => {
270
- await mailQueue.destroy({ drain: true }); // stop scheduler, then wait for in-flight SMTPs
272
+ const finished = await mailQueue.destroy({ drain: true, schedulerTimeout: 30_000 });
273
+ if (!finished) console.error('Queue scan did not finish before scheduler timeout');
271
274
  });
272
275
  ```
273
276
 
274
- `destroy()` without `{ drain: true }` stops immediately: in-flight completions make no storage writes and their claims recover after `sendingTimeout`. Use it for forced teardown, not graceful shutdown.
277
+ 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).
275
278
 
276
279
  #### Pause / resume a server (backpressure)
277
280
 
278
- `pause()` stops this **server** instance from competing for the queue-drain lease without tearing it down (unlike `destroy()`). In-flight SMTP sends finish; other `server` instances keep draining. `resume()` resumes and triggers an immediate scan.
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.
279
282
 
280
283
  ```js
281
284
  mailQueue.pause(); // stop draining on this pod
@@ -377,7 +380,7 @@ Defaults fit moderate traffic in a single region. Reach for a [preset](#settings
377
380
  | `strictPayload` | `false` | Turn on when anything other than your own app can write to the queue storage. |
378
381
  | `revolvingInterval` | `1536` ms | Lower → faster pickup; higher → less scheduler I/O |
379
382
  | `josk.minRevolvingDelay` / `maxRevolvingDelay` | `512` / `2048` | Lower both → snappier polls, more storage load |
380
- | `josk.zombieTime` | `60000` | **Never below 60s.** Iterate releases the JoSk lease as soon as scanning ends — only a stalled storage scan can blow this. |
383
+ | `josk.zombieTime` | `60000` | **Never below 60s.** After an unclean stop during a claimed scan, JoSk 6.4 preserves the claim until `zombieTime`; use graceful destroy on redeploy. |
381
384
  | `josk.concurrency` | `Infinity` | Set `1` if scheduler ticks overlap while `iterate` still runs |
382
385
  | `josk.execute` | `'batch'` | Usually leave default; MailTime only registers one JoSk task per instance |
383
386
  | `josk.lockOwnerId` | random | Set in production for observability |
@@ -400,6 +403,30 @@ new MailTime({
400
403
 
401
404
  Transports may also set `error.mayFailOver = false`. Retry timing stays unchanged. Edge cases: [v4.1 → v5 migration guide](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4.1-v5.md#shouldfailovererror-info-email).
402
405
 
406
+ ## Recipient policies
407
+
408
+ Opt in with `recipientPolicies` to suppress recipients before SMTP or classify attributable transport rejections. Omitting it preserves existing delivery and callback behavior. Supply your own suppression store:
409
+
410
+ ```js
411
+ const suppressed = new Set(['unsubscribed@example.com']);
412
+
413
+ new MailTime({
414
+ queue, transports, josk,
415
+ recipientPolicies: [{
416
+ name: 'unsubscribe',
417
+ beforeSend({ recipients }) {
418
+ return { decisions: recipients.filter(({ address }) => suppressed.has(address))
419
+ .map(({ address }) => ({ address, status: 'suppressed', reason: 'unsubscribed' })) };
420
+ },
421
+ }],
422
+ onSuppressed(task, recipients) {
423
+ console.log(task.uuid, recipients.map(({ address }) => address));
424
+ },
425
+ });
426
+ ```
427
+
428
+ Only the SMTP envelope is filtered. Headers remain unchanged; some transports retain BCC in generated MIME. If suppressed addresses must not appear in message content, remove them from headers before enqueueing. See [recipient policy hooks, outcomes, recovery, and rollout](https://github.com/veliovgroup/mail-time/blob/master/docs/recipient-policies.md).
429
+
403
430
  ## Queue payload trust
404
431
 
405
432
  `raw` is always refused. Use `strictPayload` when queue writers should not control attachments, URLs, envelope, DKIM, or other nodemailer capabilities:
@@ -478,7 +505,7 @@ await mailQueue.sendMail({
478
505
  | `failsToNext` | `number` | `4` | (`backup`) failures-in-a-row before rotating. |
479
506
  | `retries` | `number` | `59` | Re-send attempts after first failure. Total attempts = `retries + 1` (defaults to 60). Legacy alias `maxTries` is honored when `retries` is absent: `new MailTime({ maxTries: N })` sets total attempts to `N`. |
480
507
  | `retryDelay` | `number` (ms) | `60000` | Wait between attempts. |
481
- | `keepHistory` | `boolean` | `false` | Keep sent/failed/cancelled rows. |
508
+ | `keepHistory` | `boolean` | `false` | Keep terminal rows, including settled policy outcomes. |
482
509
  | `concatEmails` | `boolean \| { subject?: string }` | `false` | Fold same-`to` letters into one. Pass `{ subject: 'X' }` to set the folded-letter subject inline; the string supports the `{{count}}` placeholder and overrides `concatSubject`. |
483
510
  | `concatSubject` | `string` | `'Multiple notifications'` | Subject when folded. Supports `{{count}}` for the folded letter count. |
484
511
  | `concatDelimiter` | `string` | `'<hr>'` | Separator between folded bodies. |
@@ -497,8 +524,11 @@ await mailQueue.sendMail({
497
524
  | `prefix` | `string` | `''` | Queue namespace. **Same** on every `client` and `server` for one logical queue; **different** per email class. Inherited by the queue adapter; JoSk scheduler uses `mailTimeQueue<prefix>`. |
498
525
  | `from` | `string \| (transport, details) => string` | — | Strongly recommended for spam-passing `From:` formatting. `details` is `{ index, from }` — see [Resolving the sender](#resolving-the-sender). |
499
526
  | `debug` | `boolean` | `false` | Verbose logs. |
500
- | `onSent(email, info)` | `function` | — | Called once the task is fully delivered. `email.mailOptions[i].accepted` lists every address that got through (across all attempts). |
501
- | `onError(error, email, info)` | `function` | — | Called once the retry budget is exhausted with at least one un-accepted recipient. `email.mailOptions[i].rejected` lists each un-delivered address with its last error. Also fires once per transport that fails `verify()` at startup with `email === null` and `info = { transportIndex, phase: 'verify' }`. |
527
+ | `onSent(email, info)` | `function` | — | Without policies, called once the task is fully delivered. Policy mode adds grouped `recipients, summary` arguments. `email.mailOptions[i].accepted` lists every address that got through (across all attempts). |
528
+ | `onError(error, email, info)` | `function` | — | Without policies, called once the retry budget is exhausted with at least one un-accepted recipient. Policy mode adds grouped `recipients, summary` arguments. `email.mailOptions[i].rejected` lists each un-delivered address with its last error. Also fires once per transport that fails `verify()` at startup with `email === null` and `info = { transportIndex, phase: 'verify' }`. |
529
+ | `recipientPolicies` | `MailTimeRecipientPolicy[]` | — | Optional nonempty provider list. See [Recipient policies](#recipient-policies). |
530
+ | `onSuppressed(task, recipients, summary)` | `function` | — | Terminal suppressed group in policy mode. |
531
+ | `onRejected(task, recipients, summary)` | `function` | — | Terminal permanently rejected group in policy mode. |
502
532
 
503
533
  ### JoSk options
504
534
 
@@ -509,7 +539,7 @@ await mailQueue.sendMail({
509
539
  | `adapter` | — | Either a constructed adapter or a config object: `{ type: 'redis' \| 'mongo' \| 'postgres', client \| db, prefix?, resetOnInit?, useHashTags? }`. MailTime constructs the adapter from the config object. Set `useHashTags: true` on Redis/KeyDB Cluster. |
510
540
  | `minRevolvingDelay` | `512` | Lower bound of poll window. |
511
541
  | `maxRevolvingDelay` | `2048` | Upper bound. |
512
- | `zombieTime` | `60000` | Re-claim if `queue.iterate()` runs longer than this. **Do not drop below 60s.** |
542
+ | `zombieTime` | `60000` | Recovery after an unfinished scan or unclean worker death. **Do not drop below 60s.** |
513
543
  | `lockLeaseTime` | `30000` | JoSk scheduler lease TTL. JoSk floors it at `2 * maxRevolvingDelay + 1000`; increase for slow storage claim batches. Separate from `zombieTime`, which controls task recovery. |
514
544
  | `execute` | `'batch'` | JoSk scheduler batching; low impact for MailTime (one interval task per instance). |
515
545
  | `concurrency` | `Infinity` | Cap overlapping JoSk handler runs on **this** process (`1` if ticks pile up). |
@@ -528,7 +558,7 @@ For deeper JoSk semantics, install the JoSk skill: **`npx skills add veliovgroup
528
558
  - `cancel(uuid)` — alias of `cancelMail`.
529
559
  - `ping()` → `Promise<{status, code, statusCode, paused?, error?}>`. Pings scheduler then queue; `paused` reflects `isPaused`.
530
560
  - `ready()` → `Promise<MailTime>`. Awaits all startup work; rejects with `.cause` on storage failure.
531
- - `destroy(opts?)` → `boolean` or `Promise<boolean>` when `{ drain: true }`. Stops scheduler. Idempotent. Use `await destroy({ drain: true })` for graceful shutdown; plain `destroy()` aborts completion writes.
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.
532
562
  - `drain()` → `Promise<void>`. Resolves once every in-flight SMTP attempt finishes. Useful in tests and graceful-shutdown paths.
533
563
  - `pause()` / `resume()` → `boolean`. Server-only reversible backpressure; no-ops on `client` or after `destroy()`. See [Shutdown](#6-shutdown).
534
564
  - `isPaused` → `boolean`. Read-only; always `false` on `client`.
@@ -545,7 +575,7 @@ For custom adapters see [docs/queue-api.md](https://github.com/veliovgroup/mail-
545
575
 
546
576
  ### Redis Cluster
547
577
 
548
- Set `useHashTags: true` on both `RedisQueue` and its JoSk Redis adapter. Queue state then uses `mailtime:{prefix}:letters` plus a tagged sorted-set schedule, so node-redis `createCluster()` can route every Lua operation to one slot. One prefix is one slot; shard high-volume traffic across prefixes. Tagged `iterate` returns at most 100 due rows per tick. Existing standalone keys are not read in this mode. Follow [Redis Cluster migration](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v5-v5.1.md) before cutover.
578
+ Set `useHashTags: true` on both `RedisQueue` and its JoSk Redis adapter. Queue state then uses `mailtime:{prefix}:letters` plus a tagged sorted-set schedule, so node-redis `createCluster()` can route Lua operations to one slot with Redis client 4 or 5. JoSk 6.4 rejects Cluster clients without the setting. One prefix is one slot; shard high-volume traffic across prefixes. Tagged `iterate` returns at most 100 due rows per tick. Existing standalone keys are not read in this mode. Follow [Redis Cluster migration](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v5-v5.1.md) before cutover.
549
579
 
550
580
  ### Module functions
551
581
 
@@ -608,12 +638,13 @@ Mixed clusters (some Node, some Bun) share one schedule under the same `prefix`
608
638
 
609
639
  ## Support this project
610
640
 
641
+ - Try [🌉 Bridge CDN](https://bridge-cdn.com/?ref=github-mail-time-repo-footer) - A SEO-focused Cloudflare alternative. CDN, DNS, IndexNow, Prerender, SEO, Edge Computing.
642
+ - Try [☄️ meteor-files.com](https://meteor-files.com/?ref=github-mail-time-repo-footer).
643
+ - Try [▲ ostr.io](https://ostr.io?ref=github-mail-time-repo-footer) for server monitoring, web analytics, web-CRON, and SEO pre-rendering.
611
644
  - Star on [GitHub](https://github.com/veliovgroup/mail-time) and [NPM](https://www.npmjs.com/package/mail-time).
612
645
  - [Sponsor maintainer on GitHub](https://github.com/sponsors/dr-dimitru).
613
646
  - [Sponsor veliovgroup on GitHub](https://github.com/sponsors/veliovgroup).
614
647
  - [PayPal](https://paypal.me/veliovgroup).
615
- - Try [☄️ meteor-files.com](https://meteor-files.com/?ref=github-mail-time-repo-footer).
616
- - Try [▲ ostr.io](https://ostr.io?ref=github-mail-time-repo-footer) for server monitoring, web analytics, web-CRON, and SEO pre-rendering.
617
648
 
618
649
  [npm-url]: https://www.npmjs.com/package/mail-time
619
650
  [badge-npm-v]: https://img.shields.io/npm/v/mail-time.svg
@@ -34,6 +34,7 @@ export class MongoQueue {
34
34
  */
35
35
  constructor(opts: MongoQueueOption);
36
36
  name: string;
37
+ supportsRecipientPolicies: boolean;
37
38
  db: Db;
38
39
  prefix: any;
39
40
  collection: MongoCollection | undefined;
package/adapters/mongo.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  debug,
3
3
  logError,
4
+ hasOwnProp,
4
5
  isSendClaimUpdate,
5
6
  isSendLeaseGuardedUpdate,
6
7
  isAppendMailOptionUpdate,
@@ -80,6 +81,7 @@ class MongoQueue {
80
81
  */
81
82
  constructor (opts) {
82
83
  this.name = 'mongo-queue';
84
+ this.supportsRecipientPolicies = true;
83
85
  if (!opts || typeof opts !== 'object') {
84
86
  throw new TypeError('[mail-time] Configuration object must be passed into MongoQueue constructor');
85
87
  }
@@ -100,8 +102,8 @@ class MongoQueue {
100
102
  this.collection = this.db.collection(`__mailTimeQueue__${prefix}`);
101
103
  this.__readyPromise = Promise.all([
102
104
  ensureIndex(this.collection, { uuid: 1 }, { background: false }),
103
- ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, to: 1, sendAt: 1 }, { background: false }),
104
- ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, isSending: 1, sendingAt: 1, sendAt: 1, tries: 1 }, { background: false }),
105
+ ensureIndex(this.collection, { isSettled: 1, isSent: 1, isFailed: 1, isCancelled: 1, to: 1, sendAt: 1 }, { name: 'mailtime_policy_pending_to_v1', background: false }),
106
+ ensureIndex(this.collection, { isSettled: 1, isSent: 1, isFailed: 1, isCancelled: 1, isSending: 1, sendingAt: 1, sendAt: 1, tries: 1 }, { name: 'mailtime_policy_due_v1', background: false }),
105
107
  ]).then(() => void 0);
106
108
  }
107
109
 
@@ -200,12 +202,13 @@ class MongoQueue {
200
202
  sendAt: {
201
203
  $lte: now,
202
204
  },
203
- tries: {
204
- $lt: this.mailTimeInstance.maxTries,
205
- },
206
- $or: [
207
- { isSending: { $ne: true } },
208
- { sendingAt: { $lte: now - sendingTimeout } },
205
+ isSettled: { $ne: true },
206
+ $and: [
207
+ { $or: [{ isSending: { $ne: true } }, { sendingAt: { $lte: now - sendingTimeout } }] },
208
+ { $or: [
209
+ { tries: { $lt: this.mailTimeInstance.maxTries } },
210
+ { recipientResults: { $type: 'array' }, isSending: true, sendingAt: { $lte: now - sendingTimeout } },
211
+ ] },
209
212
  ],
210
213
  }, {
211
214
  projection: {
@@ -221,6 +224,8 @@ class MongoQueue {
221
224
  sendingAt: 1,
222
225
  mailOptions: 1,
223
226
  concatSubject: 1,
227
+ isSettled: 1,
228
+ recipientResults: 1,
224
229
  },
225
230
  });
226
231
 
@@ -229,7 +234,7 @@ class MongoQueue {
229
234
  }
230
235
 
231
236
  try {
232
- while (await cursor.hasNext()) {
237
+ while (!this.mailTimeInstance.___isStopped && await cursor.hasNext()) {
233
238
  await this.mailTimeInstance.___dispatch(await cursor.next());
234
239
  }
235
240
  } finally {
@@ -262,6 +267,8 @@ class MongoQueue {
262
267
  isFailed: false,
263
268
  isCancelled: false,
264
269
  isSending: { $ne: true },
270
+ isSettled: { $ne: true },
271
+ recipientResults: { $exists: false },
265
272
  tries: { $lt: this.mailTimeInstance.maxTries },
266
273
  sendAt: {
267
274
  $lte: sendAt,
@@ -275,6 +282,8 @@ class MongoQueue {
275
282
  isSent: 1,
276
283
  isFailed: 1,
277
284
  isCancelled: 1,
285
+ isSettled: 1,
286
+ recipientResults: 1,
278
287
  mailOptions: 1,
279
288
  },
280
289
  });
@@ -322,20 +331,20 @@ class MongoQueue {
322
331
  uuid: 1,
323
332
  isSent: 1,
324
333
  isCancelled: 1,
334
+ isSettled: 1,
325
335
  },
326
336
  });
327
337
 
328
- if (!task || task.isSent === true || task.isCancelled === true) {
338
+ if (!task || task.isSent === true || task.isCancelled === true || task.isSettled === true) {
329
339
  return false;
330
340
  }
331
-
341
+ const query = { _id: task._id, isSent: false, isCancelled: false, isSettled: { $ne: true },
342
+ $or: [{ recipientResults: { $exists: false } }, { isFailed: false }] };
332
343
  if (!this.mailTimeInstance.keepHistory) {
333
- return await this.remove(task);
344
+ return ((await this.collection.deleteOne(query))?.deletedCount || 0) >= 1;
334
345
  }
335
-
336
- return await this.update(task, {
337
- isCancelled: true,
338
- });
346
+ const result = await this.collection.updateOne(query, { $set: { isCancelled: true } });
347
+ return (result?.matchedCount || result?.modifiedCount || 0) >= 1;
339
348
  }
340
349
 
341
350
  /**
@@ -356,6 +365,8 @@ class MongoQueue {
356
365
 
357
366
  const query = { _id: task._id };
358
367
  if (isSendLeaseRemove(opts)) {
368
+ query.isSent = false;
369
+ query.isSettled = { $ne: true };
359
370
  query.tries = opts.leaseTries;
360
371
  query.isSending = true;
361
372
  query.sendingAt = opts.leaseSendingAt;
@@ -382,6 +393,8 @@ class MongoQueue {
382
393
  return false;
383
394
  }
384
395
  this.__ensurePrefix();
396
+ if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled'))
397
+ && !isSendClaimUpdate(updateObj) && !isSendLeaseGuardedUpdate(updateObj)) return false;
385
398
 
386
399
  if (isAppendMailOptionUpdate(updateObj)) {
387
400
  const res = await this.collection.updateOne({
@@ -390,6 +403,8 @@ class MongoQueue {
390
403
  isFailed: false,
391
404
  isCancelled: false,
392
405
  isSending: { $ne: true },
406
+ isSettled: { $ne: true },
407
+ recipientResults: { $exists: false },
393
408
  }, {
394
409
  $push: {
395
410
  mailOptions: updateObj.appendMailOption,
@@ -408,12 +423,15 @@ class MongoQueue {
408
423
  query.isSent = false;
409
424
  query.isFailed = false;
410
425
  query.isCancelled = false;
426
+ query.isSettled = { $ne: true };
411
427
  query.tries = task.tries;
412
428
  query.$or = [
413
429
  { isSending: { $ne: true } },
414
430
  { sendingAt: { $lte: now - sendingTimeout } },
415
431
  ];
416
432
  } else if (isSendLeaseGuardedUpdate(updateObj)) {
433
+ query.isSent = false;
434
+ query.isSettled = { $ne: true };
417
435
  query.tries = updateObj.leaseTries;
418
436
  query.isSending = true;
419
437
  query.sendingAt = updateObj.leaseSendingAt;
@@ -424,7 +442,7 @@ class MongoQueue {
424
442
  const res = await this.collection.updateOne(query, {
425
443
  $set: stripInternalUpdateMeta(updateObj),
426
444
  });
427
- if (isSendClaimUpdate(updateObj)) {
445
+ if (isSendClaimUpdate(updateObj) || isSendLeaseGuardedUpdate(updateObj)) {
428
446
  return (res?.modifiedCount || res?.matchedCount || 0) >= 1;
429
447
  }
430
448
  return (res?.modifiedCount || 0) >= 1;
@@ -17,6 +17,7 @@ export class PostgresQueue {
17
17
  */
18
18
  constructor(opts: PostgresQueueOption);
19
19
  name: string;
20
+ supportsRecipientPolicies: boolean;
20
21
  client: PostgresClient;
21
22
  prefix: any;
22
23
  /**
@@ -2,6 +2,7 @@ import { createHash } from 'crypto';
2
2
  import {
3
3
  debug,
4
4
  logError,
5
+ hasOwnProp,
5
6
  isSendClaimUpdate,
6
7
  isSendLeaseGuardedUpdate,
7
8
  isAppendMailOptionUpdate,
@@ -48,6 +49,8 @@ const fieldMap = {
48
49
  tries: 'tries',
49
50
  sendAt: 'send_at',
50
51
  isSent: 'is_sent',
52
+ isSettled: 'is_settled',
53
+ recipientResults: 'recipient_results',
51
54
  isCancelled: 'is_cancelled',
52
55
  isFailed: 'is_failed',
53
56
  isSending: 'is_sending',
@@ -77,6 +80,8 @@ const normalizeRow = (row) => {
77
80
  tries: parseInt(row.tries, 10),
78
81
  sendAt: parseInt(row.send_at, 10),
79
82
  isSent: row.is_sent,
83
+ isSettled: row.is_settled === true,
84
+ recipientResults: row.recipient_results == null ? void 0 : parseMailOptions(row.recipient_results),
80
85
  isCancelled: row.is_cancelled,
81
86
  isFailed: row.is_failed,
82
87
  isSending: row.is_sending === true,
@@ -96,6 +101,7 @@ class PostgresQueue {
96
101
  */
97
102
  constructor(opts) {
98
103
  this.name = 'postgres-queue';
104
+ this.supportsRecipientPolicies = true;
99
105
  if (!opts || typeof opts !== 'object') {
100
106
  throw new TypeError('[mail-time] Configuration object must be passed into PostgresQueue constructor');
101
107
  }
@@ -156,6 +162,8 @@ class PostgresQueue {
156
162
  tries INTEGER NOT NULL DEFAULT 0,
157
163
  send_at BIGINT NOT NULL,
158
164
  is_sent BOOLEAN NOT NULL DEFAULT false,
165
+ is_settled BOOLEAN NOT NULL DEFAULT false,
166
+ recipient_results JSONB,
159
167
  is_cancelled BOOLEAN NOT NULL DEFAULT false,
160
168
  is_failed BOOLEAN NOT NULL DEFAULT false,
161
169
  is_sending BOOLEAN NOT NULL DEFAULT false,
@@ -167,14 +175,16 @@ class PostgresQueue {
167
175
  created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
168
176
  updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
169
177
  )`);
178
+ await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS is_settled BOOLEAN NOT NULL DEFAULT false');
179
+ await this.client.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS recipient_results JSONB');
170
180
  await this.client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
171
181
  ON mail_time_queue (prefix, uuid)`);
172
182
 
173
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_due
174
- ON mail_time_queue (prefix, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
183
+ await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_due_v1
184
+ ON mail_time_queue (prefix, is_settled, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
175
185
 
176
- await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_pending_to
177
- ON mail_time_queue (prefix, to_address, is_sent, is_failed, is_cancelled, send_at)`);
186
+ await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_pending_to_v1
187
+ ON mail_time_queue (prefix, to_address, is_settled, is_sent, is_failed, is_cancelled, send_at)`);
178
188
  } finally {
179
189
  await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
180
190
  }
@@ -249,19 +259,23 @@ class PostgresQueue {
249
259
 
250
260
  try {
251
261
  const res = await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
252
- is_sending, sending_at, template, transport, concat_subject, mail_options
262
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results
253
263
  FROM mail_time_queue
254
264
  WHERE prefix = $1
255
265
  AND is_sent = false
256
266
  AND is_failed = false
257
267
  AND is_cancelled = false
268
+ AND is_settled = false
258
269
  AND send_at <= $2
259
- AND tries < $3
270
+ AND (tries < $3 OR (recipient_results IS NOT NULL AND is_sending = true AND sending_at <= $4))
260
271
  AND (is_sending = false OR sending_at <= $4)
261
272
  ORDER BY send_at ASC
262
273
  LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
263
274
 
264
275
  for (const row of res.rows || []) {
276
+ if (this.mailTimeInstance.___isStopped) {
277
+ break;
278
+ }
265
279
  await this.mailTimeInstance.___dispatch(normalizeRow(row));
266
280
  }
267
281
  } catch (iterateError) {
@@ -287,7 +301,7 @@ class PostgresQueue {
287
301
  await this.ready();
288
302
 
289
303
  const res = await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
290
- is_sending, sending_at, template, transport, concat_subject, mail_options
304
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results
291
305
  FROM mail_time_queue
292
306
  WHERE prefix = $1
293
307
  AND to_address = $2
@@ -295,6 +309,8 @@ class PostgresQueue {
295
309
  AND is_failed = false
296
310
  AND is_cancelled = false
297
311
  AND is_sending = false
312
+ AND is_settled = false
313
+ AND recipient_results IS NULL
298
314
  AND tries < $4
299
315
  AND send_at <= $3
300
316
  ORDER BY send_at DESC
@@ -325,9 +341,9 @@ class PostgresQueue {
325
341
 
326
342
  await this.client.query(`INSERT INTO mail_time_queue (
327
343
  prefix, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
328
- is_sending, sending_at, template, transport, concat_subject, mail_options, created_at, updated_at
344
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results, created_at, updated_at
329
345
  )
330
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
346
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
331
347
  ON CONFLICT (prefix, uuid) DO UPDATE SET
332
348
  to_address = EXCLUDED.to_address,
333
349
  tries = EXCLUDED.tries,
@@ -341,6 +357,8 @@ class PostgresQueue {
341
357
  transport = EXCLUDED.transport,
342
358
  concat_subject = EXCLUDED.concat_subject,
343
359
  mail_options = EXCLUDED.mail_options,
360
+ is_settled = EXCLUDED.is_settled,
361
+ recipient_results = EXCLUDED.recipient_results,
344
362
  updated_at = CURRENT_TIMESTAMP`, [
345
363
  this.prefix,
346
364
  task.uuid,
@@ -356,6 +374,8 @@ class PostgresQueue {
356
374
  task.transport,
357
375
  task.concatSubject || null,
358
376
  JSON.stringify(task.mailOptions || []),
377
+ task.isSettled === true,
378
+ task.recipientResults === void 0 ? null : JSON.stringify(task.recipientResults),
359
379
  ]);
360
380
  }
361
381
 
@@ -376,23 +396,20 @@ class PostgresQueue {
376
396
  await this.ready();
377
397
 
378
398
  const task = normalizeRow((await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
379
- template, transport, concat_subject, mail_options
399
+ template, transport, concat_subject, mail_options, is_settled, recipient_results
380
400
  FROM mail_time_queue
381
401
  WHERE prefix = $1
382
402
  AND uuid = $2
383
403
  LIMIT 1`, [this.prefix, uuid])).rows?.[0]);
384
404
 
385
- if (!task || task.isSent === true || task.isCancelled === true) {
386
- return false;
387
- }
388
-
389
- if (!this.mailTimeInstance.keepHistory) {
390
- return await this.remove(task);
391
- }
392
-
393
- return await this.update(task, {
394
- isCancelled: true,
395
- });
405
+ if (!task || task.isSent === true || task.isCancelled === true || task.isSettled === true) return false;
406
+ const operation = this.mailTimeInstance.keepHistory
407
+ ? 'UPDATE mail_time_queue SET is_cancelled = true, updated_at = CURRENT_TIMESTAMP'
408
+ : 'DELETE FROM mail_time_queue';
409
+ const result = await this.client.query(`${operation}
410
+ WHERE prefix = $1 AND uuid = $2 AND is_sent = false AND is_cancelled = false
411
+ AND is_settled = false AND (recipient_results IS NULL OR is_failed = false)`, [this.prefix, uuid]);
412
+ return (result.rowCount || 0) >= 1;
396
413
  }
397
414
 
398
415
  /**
@@ -418,7 +435,7 @@ class PostgresQueue {
418
435
  const params = [this.prefix, value];
419
436
  if (isSendLeaseRemove(opts)) {
420
437
  params.push(opts.leaseTries, opts.leaseSendingAt);
421
- leaseWhere = ` AND tries = $3 AND is_sending = true AND sending_at = $4 AND is_cancelled = false AND is_failed = false`;
438
+ leaseWhere = ` AND tries = $3 AND is_sending = true AND sending_at = $4 AND is_cancelled = false AND is_failed = false AND is_sent = false AND is_settled = false`;
422
439
  }
423
440
 
424
441
  const res = await this.client.query(`DELETE FROM mail_time_queue
@@ -443,6 +460,8 @@ class PostgresQueue {
443
460
  }
444
461
 
445
462
  await this.ready();
463
+ if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled'))
464
+ && !isSendClaimUpdate(updateObj) && !isSendLeaseGuardedUpdate(updateObj)) return false;
446
465
 
447
466
  if (isAppendMailOptionUpdate(updateObj)) {
448
467
  const where = task.id ? 'id = $3' : 'uuid = $3';
@@ -455,7 +474,9 @@ class PostgresQueue {
455
474
  AND is_sent = false
456
475
  AND is_failed = false
457
476
  AND is_cancelled = false
458
- AND is_sending = false`, [
477
+ AND is_sending = false
478
+ AND is_settled = false
479
+ AND recipient_results IS NULL`, [
459
480
  JSON.stringify([updateObj.appendMailOption]),
460
481
  this.prefix,
461
482
  value,
@@ -475,7 +496,7 @@ class PostgresQueue {
475
496
  if (key === 'sendAt' && value instanceof Date) {
476
497
  value = +value;
477
498
  }
478
- if (key === 'mailOptions') {
499
+ if (key === 'mailOptions' || key === 'recipientResults') {
479
500
  value = JSON.stringify(value);
480
501
  }
481
502
 
@@ -499,6 +520,7 @@ class PostgresQueue {
499
520
  AND is_sent = false
500
521
  AND is_failed = false
501
522
  AND is_cancelled = false
523
+ AND is_settled = false
502
524
  AND tries = $${triesIndex}
503
525
  AND (is_sending = false OR sending_at <= $${staleIndex})
504
526
  `;
@@ -508,6 +530,8 @@ class PostgresQueue {
508
530
  values.push(updateObj.leaseSendingAt);
509
531
  const sendingAtIndex = values.length;
510
532
  claimWhere = `
533
+ AND is_sent = false
534
+ AND is_settled = false
511
535
  AND tries = $${triesIndex}
512
536
  AND is_sending = true
513
537
  AND sending_at = $${sendingAtIndex}
@@ -3,9 +3,12 @@ export type RedisClient = {
3
3
  get: (key: string) => Promise<string | null>;
4
4
  set: (key: string, value: string, options?: object) => Promise<unknown>;
5
5
  del: (key: string | string[]) => Promise<number>;
6
- ping: () => Promise<string>;
6
+ ping?: (() => Promise<string>) | undefined;
7
+ getRandomNode?: (() => unknown) | undefined;
8
+ nodeClient?: ((...args: any[]) => any) | undefined;
9
+ sendCommand?: ((firstKey: string, isReadonly: boolean, args: string[]) => Promise<unknown>) | undefined;
7
10
  scanIterator?: ((options: object) => AsyncIterable<string | string[]>) | undefined;
8
- hGet?: ((key: string, field: string) => Promise<string | null>) | undefined;
11
+ hGet?: ((key: string, field: string) => Promise<string | null | undefined>) | undefined;
9
12
  eval?: ((script: string, options: {
10
13
  keys: string[];
11
14
  arguments: string[];
@@ -35,6 +38,7 @@ export class RedisQueue {
35
38
  */
36
39
  constructor(opts: RedisQueueOption);
37
40
  name: string;
41
+ supportsRecipientPolicies: boolean;
38
42
  client: RedisClient;
39
43
  useHashTags: boolean;
40
44
  prefix: any;