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 +46 -15
- package/adapters/mongo.d.ts +1 -0
- package/adapters/mongo.js +35 -17
- package/adapters/postgres.d.ts +1 -0
- package/adapters/postgres.js +48 -24
- package/adapters/redis.d.ts +6 -2
- package/adapters/redis.js +159 -72
- package/index.cjs +893 -163
- package/index.d.cts +137 -15
- package/index.d.ts +137 -15
- package/index.js +333 -35
- package/package.json +11 -3
- package/presets.js +15 -15
- package/recipient-policy-lease.js +90 -0
- package/recipient-policy.js +166 -0
- package/recipient-rejections.js +62 -0
- package/scripts/migrate-redis-queue-to-cluster.mjs +5 -1
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 });
|
|
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`.
|
|
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.**
|
|
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
|
|
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` | — |
|
|
501
|
-
| `onError(error, email, info)` | `function` | — |
|
|
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` |
|
|
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 }`.
|
|
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
|
|
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
|
package/adapters/mongo.d.ts
CHANGED
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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.
|
|
344
|
+
return ((await this.collection.deleteOne(query))?.deletedCount || 0) >= 1;
|
|
334
345
|
}
|
|
335
|
-
|
|
336
|
-
return
|
|
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;
|
package/adapters/postgres.d.ts
CHANGED
package/adapters/postgres.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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}
|
package/adapters/redis.d.ts
CHANGED
|
@@ -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
|
|
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;
|