mail-time 5.1.0 → 5.2.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,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,11 +269,12 @@ 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. `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
 
@@ -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; `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
 
@@ -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,14 +259,15 @@ 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]);
@@ -287,7 +298,7 @@ class PostgresQueue {
287
298
  await this.ready();
288
299
 
289
300
  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
301
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results
291
302
  FROM mail_time_queue
292
303
  WHERE prefix = $1
293
304
  AND to_address = $2
@@ -295,6 +306,8 @@ class PostgresQueue {
295
306
  AND is_failed = false
296
307
  AND is_cancelled = false
297
308
  AND is_sending = false
309
+ AND is_settled = false
310
+ AND recipient_results IS NULL
298
311
  AND tries < $4
299
312
  AND send_at <= $3
300
313
  ORDER BY send_at DESC
@@ -325,9 +338,9 @@ class PostgresQueue {
325
338
 
326
339
  await this.client.query(`INSERT INTO mail_time_queue (
327
340
  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
341
+ is_sending, sending_at, template, transport, concat_subject, mail_options, is_settled, recipient_results, created_at, updated_at
329
342
  )
330
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
343
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
331
344
  ON CONFLICT (prefix, uuid) DO UPDATE SET
332
345
  to_address = EXCLUDED.to_address,
333
346
  tries = EXCLUDED.tries,
@@ -341,6 +354,8 @@ class PostgresQueue {
341
354
  transport = EXCLUDED.transport,
342
355
  concat_subject = EXCLUDED.concat_subject,
343
356
  mail_options = EXCLUDED.mail_options,
357
+ is_settled = EXCLUDED.is_settled,
358
+ recipient_results = EXCLUDED.recipient_results,
344
359
  updated_at = CURRENT_TIMESTAMP`, [
345
360
  this.prefix,
346
361
  task.uuid,
@@ -356,6 +371,8 @@ class PostgresQueue {
356
371
  task.transport,
357
372
  task.concatSubject || null,
358
373
  JSON.stringify(task.mailOptions || []),
374
+ task.isSettled === true,
375
+ task.recipientResults === void 0 ? null : JSON.stringify(task.recipientResults),
359
376
  ]);
360
377
  }
361
378
 
@@ -376,23 +393,20 @@ class PostgresQueue {
376
393
  await this.ready();
377
394
 
378
395
  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
396
+ template, transport, concat_subject, mail_options, is_settled, recipient_results
380
397
  FROM mail_time_queue
381
398
  WHERE prefix = $1
382
399
  AND uuid = $2
383
400
  LIMIT 1`, [this.prefix, uuid])).rows?.[0]);
384
401
 
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
- });
402
+ if (!task || task.isSent === true || task.isCancelled === true || task.isSettled === true) return false;
403
+ const operation = this.mailTimeInstance.keepHistory
404
+ ? 'UPDATE mail_time_queue SET is_cancelled = true, updated_at = CURRENT_TIMESTAMP'
405
+ : 'DELETE FROM mail_time_queue';
406
+ const result = await this.client.query(`${operation}
407
+ WHERE prefix = $1 AND uuid = $2 AND is_sent = false AND is_cancelled = false
408
+ AND is_settled = false AND (recipient_results IS NULL OR is_failed = false)`, [this.prefix, uuid]);
409
+ return (result.rowCount || 0) >= 1;
396
410
  }
397
411
 
398
412
  /**
@@ -418,7 +432,7 @@ class PostgresQueue {
418
432
  const params = [this.prefix, value];
419
433
  if (isSendLeaseRemove(opts)) {
420
434
  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`;
435
+ 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
436
  }
423
437
 
424
438
  const res = await this.client.query(`DELETE FROM mail_time_queue
@@ -443,6 +457,8 @@ class PostgresQueue {
443
457
  }
444
458
 
445
459
  await this.ready();
460
+ if ((hasOwnProp(updateObj, 'recipientResults') || hasOwnProp(updateObj, 'isSettled'))
461
+ && !isSendClaimUpdate(updateObj) && !isSendLeaseGuardedUpdate(updateObj)) return false;
446
462
 
447
463
  if (isAppendMailOptionUpdate(updateObj)) {
448
464
  const where = task.id ? 'id = $3' : 'uuid = $3';
@@ -455,7 +471,9 @@ class PostgresQueue {
455
471
  AND is_sent = false
456
472
  AND is_failed = false
457
473
  AND is_cancelled = false
458
- AND is_sending = false`, [
474
+ AND is_sending = false
475
+ AND is_settled = false
476
+ AND recipient_results IS NULL`, [
459
477
  JSON.stringify([updateObj.appendMailOption]),
460
478
  this.prefix,
461
479
  value,
@@ -475,7 +493,7 @@ class PostgresQueue {
475
493
  if (key === 'sendAt' && value instanceof Date) {
476
494
  value = +value;
477
495
  }
478
- if (key === 'mailOptions') {
496
+ if (key === 'mailOptions' || key === 'recipientResults') {
479
497
  value = JSON.stringify(value);
480
498
  }
481
499
 
@@ -499,6 +517,7 @@ class PostgresQueue {
499
517
  AND is_sent = false
500
518
  AND is_failed = false
501
519
  AND is_cancelled = false
520
+ AND is_settled = false
502
521
  AND tries = $${triesIndex}
503
522
  AND (is_sending = false OR sending_at <= $${staleIndex})
504
523
  `;
@@ -508,6 +527,8 @@ class PostgresQueue {
508
527
  values.push(updateObj.leaseSendingAt);
509
528
  const sendingAtIndex = values.length;
510
529
  claimWhere = `
530
+ AND is_sent = false
531
+ AND is_settled = false
511
532
  AND tries = $${triesIndex}
512
533
  AND is_sending = true
513
534
  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;