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/index.js
CHANGED
|
@@ -7,6 +7,10 @@ import { PostgresQueue } from './adapters/postgres.js';
|
|
|
7
7
|
import { mailTimePreset, presets, presetNames } from './presets.js';
|
|
8
8
|
import { debug, escapeHtml, logError, hasOwnProp, deepMerge, equals, isPlainObject, extractEmail, toAddressList, filterAddressField } from './helpers.js';
|
|
9
9
|
|
|
10
|
+
import { validateRecipientPolicies, policyError, normalizePolicyAddress, preparePolicyEnvelope, evaluatePolicyPhase, mergePolicyResults, summarizePolicyTask } from './recipient-policy.js';
|
|
11
|
+
import { normalizeRejections } from './recipient-rejections.js';
|
|
12
|
+
import { RecipientPolicyLease } from './recipient-policy-lease.js';
|
|
13
|
+
|
|
10
14
|
const noop = () => {};
|
|
11
15
|
const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
|
|
12
16
|
|
|
@@ -75,11 +79,19 @@ const createPool = (concurrency) => {
|
|
|
75
79
|
let active = 0;
|
|
76
80
|
let drainResolvers = [];
|
|
77
81
|
|
|
82
|
+
const settleDrain = () => {
|
|
83
|
+
if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
|
|
84
|
+
const resolvers = drainResolvers;
|
|
85
|
+
drainResolvers = [];
|
|
86
|
+
for (const r of resolvers) r();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
78
90
|
const tryStart = () => {
|
|
79
91
|
while (active < limit && queue.length > 0) {
|
|
80
92
|
const job = queue.shift();
|
|
81
93
|
active++;
|
|
82
|
-
job.resolveSlot();
|
|
94
|
+
job.resolveSlot(true);
|
|
83
95
|
Promise.resolve()
|
|
84
96
|
.then(job.fn)
|
|
85
97
|
.catch((poolError) => {
|
|
@@ -87,11 +99,7 @@ const createPool = (concurrency) => {
|
|
|
87
99
|
})
|
|
88
100
|
.finally(() => {
|
|
89
101
|
active--;
|
|
90
|
-
|
|
91
|
-
const resolvers = drainResolvers;
|
|
92
|
-
drainResolvers = [];
|
|
93
|
-
for (const r of resolvers) r();
|
|
94
|
-
}
|
|
102
|
+
settleDrain();
|
|
95
103
|
tryStart();
|
|
96
104
|
});
|
|
97
105
|
}
|
|
@@ -100,10 +108,11 @@ const createPool = (concurrency) => {
|
|
|
100
108
|
return {
|
|
101
109
|
/**
|
|
102
110
|
* Queue `fn` for execution under the concurrency limit.
|
|
103
|
-
* The returned Promise resolves as soon as a slot is acquired and `fn` has started
|
|
111
|
+
* The returned Promise resolves `true` as soon as a slot is acquired and `fn` has started,
|
|
112
|
+
* or `false` if `cancelQueued()` dropped the job before it started (`fn` never runs).
|
|
104
113
|
* It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
|
|
105
114
|
* @param {() => Promise<void>} fn
|
|
106
|
-
* @returns {Promise<
|
|
115
|
+
* @returns {Promise<boolean>}
|
|
107
116
|
*/
|
|
108
117
|
dispatch(fn) {
|
|
109
118
|
return new Promise((resolveSlot) => {
|
|
@@ -111,6 +120,16 @@ const createPool = (concurrency) => {
|
|
|
111
120
|
tryStart();
|
|
112
121
|
});
|
|
113
122
|
},
|
|
123
|
+
/**
|
|
124
|
+
* Drop every job still waiting for a slot. Running jobs are untouched.
|
|
125
|
+
* @returns {number} dropped jobs
|
|
126
|
+
*/
|
|
127
|
+
cancelQueued() {
|
|
128
|
+
const dropped = queue.splice(0);
|
|
129
|
+
for (const job of dropped) job.resolveSlot(false);
|
|
130
|
+
settleDrain();
|
|
131
|
+
return dropped.length;
|
|
132
|
+
},
|
|
114
133
|
drain() {
|
|
115
134
|
if (active === 0 && queue.length === 0) {
|
|
116
135
|
return Promise.resolve();
|
|
@@ -208,11 +227,32 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
208
227
|
*/
|
|
209
228
|
|
|
210
229
|
/**
|
|
211
|
-
* @typedef {{ [key: string]: any, ping: () => Promise<MailTimePingResult>, setInterval: (func: (...args: any[]) => unknown, delay: number, uid: string) => Promise<string>, destroy: () => boolean, pause: (timerId?: string) => boolean, resume: (timerId?: string) => boolean }} MailTimeScheduler
|
|
230
|
+
* @typedef {{ [key: string]: any, ping: () => Promise<MailTimePingResult>, setInterval: (func: (...args: any[]) => unknown, delay: number, uid: string) => Promise<string>, destroy: () => boolean, shutdown: (opts?: { timeout?: number }) => Promise<boolean>, pause: (timerId?: string) => boolean, resume: (timerId?: string) => boolean }} MailTimeScheduler
|
|
231
|
+
*/
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @typedef {string | { address: string, name?: string }} MailTimeMailbox
|
|
212
235
|
*/
|
|
213
236
|
|
|
214
237
|
/**
|
|
215
|
-
* @typedef {{
|
|
238
|
+
* @typedef {{ address: string, sources: Array<'envelope' | 'to' | 'cc' | 'bcc'> }} MailTimePolicyRecipient
|
|
239
|
+
* @typedef {{ address: string, status: 'suppressed' | 'rejected' | 'retry', reason: string }} MailTimePolicyDecision
|
|
240
|
+
* @typedef {{ decisions?: MailTimePolicyDecision[] }} MailTimePolicyResult
|
|
241
|
+
* @typedef {{ index: number, name?: string }} MailTimePolicyTransport
|
|
242
|
+
* @typedef {{ from?: string, to: string[] }} MailTimePolicyEnvelope
|
|
243
|
+
* @typedef {{ task: MailTimeTask, attempt: number, transport: MailTimePolicyTransport, envelope: MailTimePolicyEnvelope, recipients: MailTimePolicyRecipient[] }} MailTimePolicyContext
|
|
244
|
+
* @typedef {MailTimePolicyContext} MailTimeBeforeSendPolicyContext
|
|
245
|
+
* @typedef {{ address: string | null, command?: string, responseCode?: number, response?: string, message?: string, transportIndex: number, transportName?: string }} MailTimeStructuredRejection
|
|
246
|
+
* @typedef {MailTimePolicyContext & { error?: unknown, info?: unknown, rejections: MailTimeStructuredRejection[] }} MailTimeRejectionPolicyContext
|
|
247
|
+
* @typedef {{ address: string, status: 'sent' | 'error' | 'suppressed' | 'rejected', sources?: Array<'envelope' | 'to' | 'cc' | 'bcc'>, reasons: Array<{ provider: string, reason: string }>, attempt: number, transportIndex?: number, transportName?: string, command?: string, responseCode?: number, response?: string, message?: string }} MailTimeRecipientResult
|
|
248
|
+
* @typedef {MailTimeRejectionPolicyContext & { decisions: MailTimeRecipientResult[] }} MailTimeRecipientAttemptContext
|
|
249
|
+
* @typedef {{ uuid: string, tries: number, isSettled: boolean, recipients: { sent: MailTimeRecipientResult[], error: MailTimeRecipientResult[], suppressed: MailTimeRecipientResult[], rejected: MailTimeRecipientResult[] } }} MailTimeRecipientSummary
|
|
250
|
+
* @typedef {{ name: string, failureMode?: 'retry' | 'continue', beforeSend?: (context: MailTimeBeforeSendPolicyContext) => void | MailTimePolicyResult | Promise<void | MailTimePolicyResult>, classifyRejections?: (context: MailTimeRejectionPolicyContext) => void | MailTimePolicyResult | Promise<void | MailTimePolicyResult>, observeAttempt?: (context: MailTimeRecipientAttemptContext) => void | Promise<void> }} MailTimeRecipientPolicy
|
|
251
|
+
* @description Policy contexts are read-only by contract. Providers must bound their own I/O; renewal budgets do not limit hook duration.
|
|
252
|
+
*/
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @typedef {{ uuid: string, to?: MailTimeMailbox | MailTimeMailbox[], tries: number, sendAt: number, isSent: boolean, isSettled?: boolean, recipientResults?: MailTimeRecipientResult[], isCancelled: boolean, isFailed: boolean, isSending?: boolean, sendingAt?: number, template?: string | false, transport: number, concatSubject?: string | false, mailOptions: MailTimeMailOptions[] }} MailTimeTask
|
|
216
256
|
*/
|
|
217
257
|
|
|
218
258
|
/**
|
|
@@ -220,7 +260,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
220
260
|
*/
|
|
221
261
|
|
|
222
262
|
/**
|
|
223
|
-
* @typedef {{ ping: () => Promise<MailTimePingResult>, iterate: (opts?: MailTimeIterateOptions) => Promise<void> | void, getPendingTo: (to: string, sendAt: number) => Promise<MailTimeTask | object | null>, push: (email: MailTimeTask) => Promise<void> | void, cancel: (uuid: string) => Promise<boolean>, remove: (email: MailTimeTask | object, opts?: { leaseTries: number, leaseSendingAt: number }) => Promise<boolean>, update: (email: MailTimeTask | object, updateObj: object) => Promise<boolean>, ready?: () => Promise<void
|
|
263
|
+
* @typedef {{ ping: () => Promise<MailTimePingResult>, iterate: (opts?: MailTimeIterateOptions) => Promise<void> | void, getPendingTo: (to: string, sendAt: number) => Promise<MailTimeTask | object | null>, push: (email: MailTimeTask) => Promise<void> | void, cancel: (uuid: string) => Promise<boolean>, remove: (email: MailTimeTask | object, opts?: { leaseTries: number, leaseSendingAt: number }) => Promise<boolean>, update: (email: MailTimeTask | object, updateObj: object) => Promise<boolean>, ready?: () => Promise<void>, supportsRecipientPolicies?: boolean }} CustomQueue
|
|
224
264
|
*/
|
|
225
265
|
|
|
226
266
|
/**
|
|
@@ -228,7 +268,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
228
268
|
*/
|
|
229
269
|
|
|
230
270
|
/**
|
|
231
|
-
* @typedef {{ [key: string]: any, to:
|
|
271
|
+
* @typedef {{ [key: string]: any, to: MailTimeMailbox | MailTimeMailbox[], cc?: MailTimeMailbox | MailTimeMailbox[], bcc?: MailTimeMailbox | MailTimeMailbox[], sendAt?: Date | number, template?: string, concatSubject?: string, text?: string | false, html?: string | false, subject?: string, accepted?: string[], rejected?: MailTimeRejectedRecipient[] }} MailTimeMailOptions
|
|
232
272
|
*/
|
|
233
273
|
|
|
234
274
|
/**
|
|
@@ -240,10 +280,16 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
240
280
|
*/
|
|
241
281
|
|
|
242
282
|
/**
|
|
243
|
-
* @typedef {{ queue: RedisQueue | MongoQueue | PostgresQueue | CustomQueue, type?: 'server' | 'client', from?: string | ((transport: MailTimeTransport, details: MailTimeFromDetails) => string), transports?: MailTimeTransport[], strategy?: 'backup' | 'balancer', failsToNext?: number, shouldFailOver?: (error: unknown, info: object | undefined, email: MailTimeTask) => boolean, retries?: number, maxTries?: number, retryDelay?: number, interval?: number, keepHistory?: boolean, concatEmails?: boolean | MailTimeConcatEmailsOptions, concatSubject?: string, concatDelimiter?: string, concatDelay?: number, concatThrottling?: number, revolvingInterval?: number, mode?: 'one' | 'batch', concurrency?: number, sendingTimeout?: number, renewClaim?: boolean | number, maxRenewals?: number, strictPayload?: boolean, allowedMailFields?: string[], verifyTransports?: boolean, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, onError?: (error: unknown, email: MailTimeTask | null, details?: object) => void
|
|
283
|
+
* @typedef {{ queue: RedisQueue | MongoQueue | PostgresQueue | CustomQueue, type?: 'server' | 'client', from?: string | ((transport: MailTimeTransport, details: MailTimeFromDetails) => string), transports?: MailTimeTransport[], strategy?: 'backup' | 'balancer', failsToNext?: number, shouldFailOver?: (error: unknown, info: object | undefined, email: MailTimeTask) => boolean, retries?: number, maxTries?: number, retryDelay?: number, interval?: number, keepHistory?: boolean, concatEmails?: boolean | MailTimeConcatEmailsOptions, concatSubject?: string, concatDelimiter?: string, concatDelay?: number, concatThrottling?: number, revolvingInterval?: number, mode?: 'one' | 'batch', concurrency?: number, sendingTimeout?: number, renewClaim?: boolean | number, maxRenewals?: number, strictPayload?: boolean, allowedMailFields?: string[], verifyTransports?: boolean, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, recipientPolicies?: MailTimeRecipientPolicy[], onError?: (error: unknown, email: MailTimeTask | null, details?: object, recipients?: MailTimeRecipientResult[], summary?: MailTimeRecipientSummary) => void | Promise<void>, onSent?: (email: MailTimeTask, details?: object, recipients?: MailTimeRecipientResult[], summary?: MailTimeRecipientSummary) => void | Promise<void>, onSuppressed?: (email: MailTimeTask, recipients: MailTimeRecipientResult[], summary: MailTimeRecipientSummary) => void | Promise<void>, onRejected?: (email: MailTimeTask, recipients: MailTimeRecipientResult[], summary: MailTimeRecipientSummary) => void | Promise<void> }} MailTimeOptions
|
|
244
284
|
*/
|
|
245
285
|
|
|
246
|
-
/**
|
|
286
|
+
/**
|
|
287
|
+
* Class of MailTime.
|
|
288
|
+
* With recipientPolicies, terminal callbacks receive grouped recipients and a complete summary after persistence.
|
|
289
|
+
* isSettled includes exhausted errors; isSent means all accepted; isFailed means an error or permanent rejection.
|
|
290
|
+
* Unparseable terminal preparation failures invoke onError with an empty recipient group.
|
|
291
|
+
* Hooks must bound their own I/O. Callback notifications are best-effort, not durable exactly-once delivery.
|
|
292
|
+
*/
|
|
247
293
|
class MailTime {
|
|
248
294
|
/**
|
|
249
295
|
* Create a MailTime instance
|
|
@@ -258,6 +304,7 @@ class MailTime {
|
|
|
258
304
|
throw new Error('[mail-time] {queue} option is required: provide a MongoQueue, RedisQueue, PostgresQueue, or CustomQueue instance');
|
|
259
305
|
}
|
|
260
306
|
|
|
307
|
+
this.__recipientPolicies = validateRecipientPolicies(opts.recipientPolicies, opts.queue);
|
|
261
308
|
this.queue = opts.queue;
|
|
262
309
|
|
|
263
310
|
for (let i = queueMethods.length - 1; i >= 0; i--) {
|
|
@@ -297,6 +344,8 @@ class MailTime {
|
|
|
297
344
|
this.keepHistory = opts.keepHistory === true;
|
|
298
345
|
this.onSent = (typeof opts.onSent === 'function') ? opts.onSent.bind(this) : noop;
|
|
299
346
|
this.onError = (typeof opts.onError === 'function') ? opts.onError.bind(this) : noop;
|
|
347
|
+
this.onSuppressed = (typeof opts.onSuppressed === 'function') ? opts.onSuppressed.bind(this) : noop;
|
|
348
|
+
this.onRejected = (typeof opts.onRejected === 'function') ? opts.onRejected.bind(this) : noop;
|
|
300
349
|
|
|
301
350
|
this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
|
|
302
351
|
this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
|
|
@@ -332,6 +381,7 @@ class MailTime {
|
|
|
332
381
|
this.__isDestroyed = false;
|
|
333
382
|
this.__abortInFlight = false;
|
|
334
383
|
this.__isPaused = false;
|
|
384
|
+
this.__schedulerScans = 0;
|
|
335
385
|
this.__readyPromise = null;
|
|
336
386
|
this.__schedulerTimer = null;
|
|
337
387
|
this.__inFlight = new Set();
|
|
@@ -509,8 +559,8 @@ class MailTime {
|
|
|
509
559
|
/**
|
|
510
560
|
* @memberOf MailTime
|
|
511
561
|
* @name destroy
|
|
512
|
-
* @description Stop the scheduler and block future dispatches. Without `{ drain: true }`,
|
|
513
|
-
* @param {{ drain?: boolean }} [opts]
|
|
562
|
+
* @description Stop the scheduler and block future dispatches. Sends still waiting for a `concurrency` slot are dropped at once; their rows stay unclaimed for the next scan. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if the queue scan exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. In-flight SMTP does not count against the timeout, and the timeout does not bound SMTP drain time.
|
|
563
|
+
* @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
|
|
514
564
|
* @returns {boolean | Promise<boolean>}
|
|
515
565
|
*/
|
|
516
566
|
destroy(opts) {
|
|
@@ -518,14 +568,32 @@ class MailTime {
|
|
|
518
568
|
if (this.__isDestroyed) {
|
|
519
569
|
return false;
|
|
520
570
|
}
|
|
571
|
+
if (opts?.drain === true && opts.schedulerTimeout !== void 0
|
|
572
|
+
&& (typeof opts.schedulerTimeout !== 'number' || !Number.isFinite(opts.schedulerTimeout) || opts.schedulerTimeout < 0)) {
|
|
573
|
+
throw new Error('[mail-time] [destroy] schedulerTimeout must be a finite non-negative number');
|
|
574
|
+
}
|
|
521
575
|
|
|
522
576
|
this.__isDestroyed = true;
|
|
523
577
|
this.__isPaused = false;
|
|
524
|
-
|
|
525
|
-
|
|
578
|
+
// Queued jobs never started SMTP; dropping them lets a scan blocked on a
|
|
579
|
+
// pool slot return, so JoSk shutdown does not wait for in-flight sends.
|
|
580
|
+
this.__pool.cancelQueued();
|
|
581
|
+
if (opts?.drain === true) {
|
|
582
|
+
return (async () => {
|
|
583
|
+
let finished = true;
|
|
584
|
+
try {
|
|
585
|
+
if (this.scheduler) finished = await this.scheduler.shutdown({ timeout: opts.schedulerTimeout });
|
|
586
|
+
} catch (error) {
|
|
587
|
+
logError('[destroy] scheduler shutdown failed', error);
|
|
588
|
+
finished = false;
|
|
589
|
+
} finally {
|
|
590
|
+
await this.__pool.drain();
|
|
591
|
+
}
|
|
592
|
+
return finished;
|
|
593
|
+
})();
|
|
526
594
|
}
|
|
527
|
-
if (
|
|
528
|
-
|
|
595
|
+
if (this.scheduler) {
|
|
596
|
+
this.scheduler.destroy();
|
|
529
597
|
}
|
|
530
598
|
// No graceful drain requested: neutralize in-flight SMTP completions so a
|
|
531
599
|
// destroyed instance performs no storage writes, no `onSent`/`onError`
|
|
@@ -555,7 +623,7 @@ class MailTime {
|
|
|
555
623
|
/**
|
|
556
624
|
* @memberOf MailTime
|
|
557
625
|
* @name pause
|
|
558
|
-
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
626
|
+
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay unclaimed; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
559
627
|
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
560
628
|
*/
|
|
561
629
|
pause() {
|
|
@@ -566,6 +634,9 @@ class MailTime {
|
|
|
566
634
|
const paused = this.scheduler.pause();
|
|
567
635
|
if (paused) {
|
|
568
636
|
this.__isPaused = true;
|
|
637
|
+
if (this.__schedulerScans > 0) {
|
|
638
|
+
this.__pool.cancelQueued();
|
|
639
|
+
}
|
|
569
640
|
}
|
|
570
641
|
return paused;
|
|
571
642
|
}
|
|
@@ -646,7 +717,8 @@ class MailTime {
|
|
|
646
717
|
delete mailOptions.template;
|
|
647
718
|
delete mailOptions.concatSubject;
|
|
648
719
|
|
|
649
|
-
|
|
720
|
+
const isMailbox = isPlainObject(mailOptions.to) && typeof mailOptions.to.address === 'string' && mailOptions.to.address.trim().length > 0;
|
|
721
|
+
if (!isMailbox && typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
|
|
650
722
|
throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
|
|
651
723
|
}
|
|
652
724
|
|
|
@@ -806,6 +878,7 @@ class MailTime {
|
|
|
806
878
|
uuid: randomUUID(),
|
|
807
879
|
tries: 0,
|
|
808
880
|
isSent: false,
|
|
881
|
+
isSettled: false,
|
|
809
882
|
sendAt: opts.sendAt,
|
|
810
883
|
isFailed: false,
|
|
811
884
|
isSending: false,
|
|
@@ -889,9 +962,11 @@ class MailTime {
|
|
|
889
962
|
compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
|
|
890
963
|
}
|
|
891
964
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
965
|
+
for (const field of ['html', 'text', 'subject']) {
|
|
966
|
+
if (compiledOpts[field] === void 0 || compiledOpts[field] === null) {
|
|
967
|
+
compiledOpts[field] = '';
|
|
968
|
+
}
|
|
969
|
+
}
|
|
895
970
|
|
|
896
971
|
const mailOptionsList = task.mailOptions || [];
|
|
897
972
|
const isMulti = mailOptionsList.length > 1;
|
|
@@ -939,7 +1014,7 @@ class MailTime {
|
|
|
939
1014
|
}
|
|
940
1015
|
|
|
941
1016
|
const acceptedSet = collectAcceptedSet(task);
|
|
942
|
-
if (acceptedSet.size > 0) {
|
|
1017
|
+
if (!this.__recipientPolicies && acceptedSet.size > 0) {
|
|
943
1018
|
compiledOpts.to = filterAddressField(compiledOpts.to, acceptedSet);
|
|
944
1019
|
compiledOpts.cc = filterAddressField(compiledOpts.cc, acceptedSet);
|
|
945
1020
|
compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
|
|
@@ -1152,7 +1227,7 @@ class MailTime {
|
|
|
1152
1227
|
};
|
|
1153
1228
|
|
|
1154
1229
|
const renew = async () => {
|
|
1155
|
-
if (stopped || this.
|
|
1230
|
+
if (stopped || this.__abortInFlight) {
|
|
1156
1231
|
halt();
|
|
1157
1232
|
return;
|
|
1158
1233
|
}
|
|
@@ -1205,17 +1280,215 @@ class MailTime {
|
|
|
1205
1280
|
return { stop };
|
|
1206
1281
|
}
|
|
1207
1282
|
|
|
1283
|
+
/** @internal Serialize every policy checkpoint with this attempt's claim renewal. */
|
|
1284
|
+
async ___sendWithRecipientPolicies(task) {
|
|
1285
|
+
if (!task || task.isSent || task.isFailed || task.isCancelled || task.isSettled || this.__abortInFlight) return;
|
|
1286
|
+
const recoveryOnly = Array.isArray(task.recipientResults) && (task.tries >= this.maxTries
|
|
1287
|
+
|| (task.recipientResults.length > 0 && task.recipientResults.every((r) => r.status === 'sent' || r.status === 'suppressed' || r.status === 'rejected')));
|
|
1288
|
+
const fields = { tries: recoveryOnly ? task.tries : task.tries + 1, isSending: true, sendingAt: Math.max(Date.now(), (task.sendingAt || 0) + 1), recipientResults: task.recipientResults || [] };
|
|
1289
|
+
try {
|
|
1290
|
+
if (!await this.queue.update(task, fields)) return;
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
if (!this.__abortInFlight) logError('[recipientPolicies] claim failed', error);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
Object.assign(task, fields);
|
|
1296
|
+
const lease = new RecipientPolicyLease({
|
|
1297
|
+
task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals,
|
|
1298
|
+
shouldAbort: () => this.__abortInFlight,
|
|
1299
|
+
report: (error) => logError('[recipientPolicies] lease persistence failed', error),
|
|
1300
|
+
});
|
|
1301
|
+
try {
|
|
1302
|
+
if (!lease.active) return;
|
|
1303
|
+
if (recoveryOnly) {
|
|
1304
|
+
let preparationFailure = false;
|
|
1305
|
+
if (!task.recipientResults.length) {
|
|
1306
|
+
try {
|
|
1307
|
+
const compiled = this.___compileMailOpts(this.transports[task.transport], task);
|
|
1308
|
+
const prepared = preparePolicyEnvelope(compiled);
|
|
1309
|
+
task.recipientResults = mergePolicyResults([], prepared.recipients, { retryFailure: false, decisions: [] }, {
|
|
1310
|
+
phase: 'beforeSend', attempt: task.tries, transportIndex: task.transport, accepted: [...collectAcceptedSet(task)],
|
|
1311
|
+
});
|
|
1312
|
+
} catch { preparationFailure = true; }
|
|
1313
|
+
}
|
|
1314
|
+
await this.___completePolicyTask(task, lease, policyError('last attempt interrupted; completing durable recipient state'), void 0, preparationFailure);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (!this.___isHealthyTransport(task.transport)) task.transport = this.___nextHealthyTransport(task.transport);
|
|
1318
|
+
const transport = this.transports[task.transport];
|
|
1319
|
+
const compiled = this.___compileMailOpts(transport, task);
|
|
1320
|
+
if (!compiled.from) compiled.from = MailTime.transportFrom(transport);
|
|
1321
|
+
const prepared = preparePolicyEnvelope(compiled, task.recipientResults);
|
|
1322
|
+
const transportName = typeof transport.name === 'string' ? transport.name : transport.options?.name;
|
|
1323
|
+
const details = { phase: 'beforeSend', attempt: task.tries, transportIndex: task.transport, transportName, accepted: [...collectAcceptedSet(task)] };
|
|
1324
|
+
task.recipientResults = mergePolicyResults(task.recipientResults, prepared.recipients, { retryFailure: false, decisions: [] }, details);
|
|
1325
|
+
const pending = new Set(task.recipientResults.filter((r) => r.status === 'error').map((r) => r.address));
|
|
1326
|
+
const context = {
|
|
1327
|
+
task, attempt: task.tries, transport: { index: task.transport, ...(typeof transportName === 'string' ? { name: transportName.slice(0, 128) } : {}) },
|
|
1328
|
+
envelope: { ...prepared.envelope, to: prepared.envelope.to.filter((a) => pending.has(a)) },
|
|
1329
|
+
recipients: prepared.recipients.filter((r) => pending.has(r.address)),
|
|
1330
|
+
};
|
|
1331
|
+
if (!pending.size) {
|
|
1332
|
+
await this.___completePolicyTask(task, lease);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
const evaluated = await evaluatePolicyPhase(this.__recipientPolicies, 'beforeSend', context, (error, name, hook) => {
|
|
1336
|
+
if (!this.__abortInFlight) logError(`[recipientPolicies] ${name}.${hook} failed`, error);
|
|
1337
|
+
});
|
|
1338
|
+
if (!lease.active) return;
|
|
1339
|
+
task.recipientResults = mergePolicyResults(task.recipientResults, context.recipients, evaluated, details);
|
|
1340
|
+
if (!await lease.update({ recipientResults: task.recipientResults, mailOptions: task.mailOptions })) return;
|
|
1341
|
+
if (!lease.active) return;
|
|
1342
|
+
if (evaluated.retryFailure) {
|
|
1343
|
+
await this.___retryPolicyTask(task, lease, policyError('beforeSend provider failed'), void 0, true);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
const eligible = new Set(task.recipientResults.filter((r) => r.status === 'error').map((r) => r.address));
|
|
1347
|
+
if (!eligible.size) {
|
|
1348
|
+
await this.___completePolicyTask(task, lease);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
context.envelope = { ...context.envelope, to: context.envelope.to.filter((a) => eligible.has(a)) };
|
|
1352
|
+
context.recipients = context.recipients.filter((r) => eligible.has(r.address));
|
|
1353
|
+
await this.___attemptPolicyTransport(task, lease, compiled, context);
|
|
1354
|
+
} catch (error) {
|
|
1355
|
+
if (lease.active) await this.___retryPolicyTask(task, lease, error, void 0, true, true);
|
|
1356
|
+
} finally {
|
|
1357
|
+
await lease.stop();
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/** @internal Persist SMTP acceptance before awaiting rejection classification or observation. */
|
|
1362
|
+
async ___attemptPolicyTransport(task, lease, compiled, context) {
|
|
1363
|
+
const transport = this.transports[context.transport.index];
|
|
1364
|
+
const outgoing = { ...compiled, envelope: context.envelope };
|
|
1365
|
+
const { error, info } = await new Promise((resolve) => {
|
|
1366
|
+
let called = false;
|
|
1367
|
+
const done = (error, info) => {
|
|
1368
|
+
if (called) return;
|
|
1369
|
+
called = true;
|
|
1370
|
+
resolve({ error, info });
|
|
1371
|
+
};
|
|
1372
|
+
try { transport.sendMail(outgoing, done); }
|
|
1373
|
+
catch (error) { done(error, void 0); }
|
|
1374
|
+
});
|
|
1375
|
+
if (!lease.active) return;
|
|
1376
|
+
const attempted = new Set(context.envelope.to);
|
|
1377
|
+
const accepted = new Set();
|
|
1378
|
+
for (const source of [info, error]) {
|
|
1379
|
+
for (const value of Array.isArray(source?.accepted) ? source.accepted : []) {
|
|
1380
|
+
try {
|
|
1381
|
+
const address = normalizePolicyAddress(value);
|
|
1382
|
+
if (attempted.has(address)) accepted.add(address);
|
|
1383
|
+
} catch { /* Transport records outside supported address forms remain unresolved. */ }
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
const details = { phase: 'classifyRejections', attempt: task.tries, transportIndex: context.transport.index, transportName: context.transport.name, accepted: [...accepted] };
|
|
1387
|
+
task.recipientResults = mergePolicyResults(task.recipientResults, context.recipients, { retryFailure: false, decisions: [] }, details);
|
|
1388
|
+
this.___mapPolicyMailOptions(task, false);
|
|
1389
|
+
if (!await lease.update({ recipientResults: task.recipientResults, mailOptions: task.mailOptions })) return;
|
|
1390
|
+
if (!lease.active) return;
|
|
1391
|
+
const unresolved = context.recipients.filter((r) => !accepted.has(r.address));
|
|
1392
|
+
if (unresolved.length) {
|
|
1393
|
+
const rejections = normalizeRejections(error, info, context.transport);
|
|
1394
|
+
const rejectionContext = { ...context, recipients: unresolved, error, info, rejections };
|
|
1395
|
+
const evaluated = await evaluatePolicyPhase(this.__recipientPolicies, 'classifyRejections', rejectionContext, (hookError, name, hook) => {
|
|
1396
|
+
if (!this.__abortInFlight) logError(`[recipientPolicies] ${name}.${hook} failed`, hookError);
|
|
1397
|
+
});
|
|
1398
|
+
if (!lease.active) return;
|
|
1399
|
+
task.recipientResults = mergePolicyResults(task.recipientResults, unresolved, evaluated, { ...details, rejections });
|
|
1400
|
+
const observation = { ...rejectionContext, decisions: task.recipientResults };
|
|
1401
|
+
for (const provider of this.__recipientPolicies) {
|
|
1402
|
+
if (!lease.active) return;
|
|
1403
|
+
if (typeof provider.observeAttempt !== 'function') continue;
|
|
1404
|
+
try { await provider.observeAttempt(observation); }
|
|
1405
|
+
catch (hookError) {
|
|
1406
|
+
if (!this.__abortInFlight) logError(`[recipientPolicies] ${provider.name}.observeAttempt failed`, hookError);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
if (!lease.active) return;
|
|
1411
|
+
if (task.recipientResults.some((r) => r.status === 'error')) {
|
|
1412
|
+
await this.___retryPolicyTask(task, lease, error || policyError('recipients remain unaccepted'), info, !!error || accepted.size === 0);
|
|
1413
|
+
} else {
|
|
1414
|
+
await this.___completePolicyTask(task, lease, error, info);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/** @internal Keep legacy per-mail-option state limited to actual envelope recipients. */
|
|
1419
|
+
___mapPolicyMailOptions(task, terminal) {
|
|
1420
|
+
const results = new Map(task.recipientResults.map((r) => [r.address, r]));
|
|
1421
|
+
for (const option of task.mailOptions || []) {
|
|
1422
|
+
const addresses = new Set();
|
|
1423
|
+
for (const field of ['to', 'cc', 'bcc']) {
|
|
1424
|
+
const values = Array.isArray(option[field]) ? option[field] : [option[field]];
|
|
1425
|
+
for (const value of values) {
|
|
1426
|
+
try { addresses.add(normalizePolicyAddress(value)); } catch { /* Explicit envelopes permit complex headers. */ }
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
const accepted = new Set(Array.isArray(option.accepted) ? option.accepted : []);
|
|
1430
|
+
const rejected = [];
|
|
1431
|
+
for (const address of addresses) {
|
|
1432
|
+
const result = results.get(address);
|
|
1433
|
+
if (result?.status === 'sent') accepted.add(address);
|
|
1434
|
+
if (terminal && (result?.status === 'error' || result?.status === 'rejected')) {
|
|
1435
|
+
rejected.push({ address, error: result.message || result.response || result.reasons[0]?.reason || 'Recipient not accepted by transport' });
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
option.accepted = [...accepted];
|
|
1439
|
+
if (terminal) option.rejected = rejected;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/** @internal Release a policy claim with all durable outcomes, or settle at exhaustion. */
|
|
1444
|
+
async ___retryPolicyTask(task, lease, error, info, allowRotation, preparationFailure = false) {
|
|
1445
|
+
if (!lease.active) return false;
|
|
1446
|
+
if (task.tries >= this.maxTries) return await this.___completePolicyTask(task, lease, error, info, preparationFailure);
|
|
1447
|
+
let transport = task.transport;
|
|
1448
|
+
if (allowRotation && this.strategy === 'backup' && this.transports.length > 1
|
|
1449
|
+
&& task.tries % this.failsToNext === 0 && this.___mayFailOver(error, info, task)) transport = this.___nextHealthyTransport(transport);
|
|
1450
|
+
return await lease.finish({ isSending: false, sendingAt: 0, sendAt: Date.now() + this.retryDelay, transport,
|
|
1451
|
+
recipientResults: task.recipientResults, mailOptions: task.mailOptions }, false);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** @internal Settle truthfully before invoking any best-effort terminal notification. */
|
|
1455
|
+
async ___completePolicyTask(task, lease, error, info, preparationFailure = false) {
|
|
1456
|
+
if (!lease.active) return false;
|
|
1457
|
+
this.___mapPolicyMailOptions(task, true);
|
|
1458
|
+
const results = task.recipientResults;
|
|
1459
|
+
const fields = {
|
|
1460
|
+
isSettled: true, isSent: results.length > 0 && results.every((r) => r.status === 'sent'),
|
|
1461
|
+
isFailed: preparationFailure || results.some((r) => r.status === 'error' || r.status === 'rejected'),
|
|
1462
|
+
isSending: false, sendingAt: 0, recipientResults: results, mailOptions: task.mailOptions,
|
|
1463
|
+
};
|
|
1464
|
+
const completed = await lease.finish(fields, !this.keepHistory);
|
|
1465
|
+
if (completed && !this.__abortInFlight) this.___notifyPolicyGroups(task, error, info, preparationFailure);
|
|
1466
|
+
return completed;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** @internal Empty groups are silent except unparseable terminal preparation failures. */
|
|
1470
|
+
___notifyPolicyGroups(task, error, info, preparationFailure) {
|
|
1471
|
+
const summary = summarizePolicyTask(task, true);
|
|
1472
|
+
const groups = summary.recipients;
|
|
1473
|
+
if (groups.sent.length) callHook('onSent', this.onSent, task, info, groups.sent, summary);
|
|
1474
|
+
if (!this.__abortInFlight && (groups.error.length || (preparationFailure && !task.recipientResults.length))) {
|
|
1475
|
+
callHook('onError', this.onError, error || policyError('recipients remain unaccepted'), task, info, groups.error, summary);
|
|
1476
|
+
}
|
|
1477
|
+
if (!this.__abortInFlight && groups.suppressed.length) callHook('onSuppressed', this.onSuppressed, task, groups.suppressed, summary);
|
|
1478
|
+
if (!this.__abortInFlight && groups.rejected.length) callHook('onRejected', this.onRejected, task, groups.rejected, summary);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1208
1481
|
/**
|
|
1209
1482
|
* @async
|
|
1210
1483
|
* @internal
|
|
1211
1484
|
* @memberOf MailTime
|
|
1212
1485
|
* @name ___dispatch
|
|
1213
|
-
* @description Queue full-lifecycle send for `task` under the bounded send pool. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
|
|
1486
|
+
* @description Queue full-lifecycle send for `task` under the bounded send pool. No-op after `destroy()`, or while paused during a scheduler-driven scan; `destroy()` and `pause()` also drop jobs still waiting for a slot. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
|
|
1214
1487
|
* @param {MailTimeTask} task - email's task object from Storage
|
|
1215
1488
|
* @returns {Promise<void 0>}
|
|
1216
1489
|
*/
|
|
1217
1490
|
async ___dispatch(task) {
|
|
1218
|
-
if (this.
|
|
1491
|
+
if (this.___isStopped) {
|
|
1219
1492
|
return;
|
|
1220
1493
|
}
|
|
1221
1494
|
if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
@@ -1226,9 +1499,9 @@ class MailTime {
|
|
|
1226
1499
|
return;
|
|
1227
1500
|
}
|
|
1228
1501
|
this.__inFlight.add(task.uuid);
|
|
1229
|
-
await this.__pool.dispatch(async () => {
|
|
1502
|
+
const started = await this.__pool.dispatch(async () => {
|
|
1230
1503
|
try {
|
|
1231
|
-
if (this.
|
|
1504
|
+
if (this.___isStopped) {
|
|
1232
1505
|
return;
|
|
1233
1506
|
}
|
|
1234
1507
|
await this.___send(task);
|
|
@@ -1236,6 +1509,20 @@ class MailTime {
|
|
|
1236
1509
|
this.__inFlight.delete(task.uuid);
|
|
1237
1510
|
}
|
|
1238
1511
|
});
|
|
1512
|
+
if (!started) {
|
|
1513
|
+
this.__inFlight.delete(task.uuid);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* @internal
|
|
1519
|
+
* @memberOf MailTime
|
|
1520
|
+
* @name ___isStopped
|
|
1521
|
+
* @description `true` after `destroy()`, or while paused during a scheduler-driven scan. Queue adapters check it to end an `iterate` scan early.
|
|
1522
|
+
* @returns {boolean}
|
|
1523
|
+
*/
|
|
1524
|
+
get ___isStopped() {
|
|
1525
|
+
return this.__isDestroyed || (this.__isPaused && this.__schedulerScans > 0);
|
|
1239
1526
|
}
|
|
1240
1527
|
|
|
1241
1528
|
/**
|
|
@@ -1248,6 +1535,12 @@ class MailTime {
|
|
|
1248
1535
|
* @returns {Promise<void 0>}
|
|
1249
1536
|
*/
|
|
1250
1537
|
async ___send(task) {
|
|
1538
|
+
if (this.__recipientPolicies) return await this.___sendWithRecipientPolicies(task);
|
|
1539
|
+
if (task?.isSettled === true) return;
|
|
1540
|
+
if (Array.isArray(task?.recipientResults)) {
|
|
1541
|
+
logError('[recipientPolicies] active policy state requires a policy-configured worker', task.uuid);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1251
1544
|
this.__debug('[private send]', task);
|
|
1252
1545
|
try {
|
|
1253
1546
|
if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
@@ -1444,10 +1737,15 @@ class MailTime {
|
|
|
1444
1737
|
return;
|
|
1445
1738
|
}
|
|
1446
1739
|
const limit = this.mode === 'one' ? 1 : Infinity;
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1740
|
+
this.__schedulerScans++;
|
|
1741
|
+
try {
|
|
1742
|
+
return await this.queue.iterate({
|
|
1743
|
+
limit,
|
|
1744
|
+
sendingTimeout: this.sendingTimeout,
|
|
1745
|
+
});
|
|
1746
|
+
} finally {
|
|
1747
|
+
this.__schedulerScans--;
|
|
1748
|
+
}
|
|
1451
1749
|
}
|
|
1452
1750
|
|
|
1453
1751
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mail-time",
|
|
3
|
-
"version": "5.1
|
|
3
|
+
"version": "5.2.1",
|
|
4
4
|
"description": "📮 Email queue extending nodemailer with multi-SMTP transports and horizontally scaled apps support",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
"adapters/redis.d.ts",
|
|
14
14
|
"adapters/redis.js",
|
|
15
15
|
"helpers.js",
|
|
16
|
+
"recipient-policy.js",
|
|
17
|
+
"recipient-rejections.js",
|
|
18
|
+
"recipient-policy-lease.js",
|
|
16
19
|
"index.cjs",
|
|
17
20
|
"index.d.cts",
|
|
18
21
|
"index.d.ts",
|
|
@@ -68,6 +71,7 @@
|
|
|
68
71
|
"test:mocha:mongo": "mocha ./test/npm-redis-mongo.js ./test/npm-mongo-redis.js ./test/npm-ha-mongo-redis.js",
|
|
69
72
|
"test:mocha:postgres": "mocha ./test/npm-postgres.js",
|
|
70
73
|
"test:types": "tsc -p ./tsconfig.types.json",
|
|
74
|
+
"test:types:meteor": "node ./test/meteor-types/check.mjs",
|
|
71
75
|
"test:redis": "mocha ./test/npm-redis-redis.js",
|
|
72
76
|
"test:mongo": "mocha ./test/npm-mongo-mongo.js",
|
|
73
77
|
"test:postgres": "mocha ./test/npm-postgres.js",
|
|
@@ -135,18 +139,22 @@
|
|
|
135
139
|
},
|
|
136
140
|
"meteor": {
|
|
137
141
|
"packageName": "ostrio:mailer",
|
|
138
|
-
"versionsFrom": [
|
|
142
|
+
"versionsFrom": [
|
|
143
|
+
"2.14",
|
|
144
|
+
"3.2"
|
|
145
|
+
],
|
|
139
146
|
"node": {
|
|
140
147
|
"2.14": ">=14",
|
|
141
148
|
"3.2": ">=18"
|
|
142
149
|
}
|
|
143
150
|
},
|
|
144
151
|
"dependencies": {
|
|
145
|
-
"josk": "^6.
|
|
152
|
+
"josk": "^6.4.0"
|
|
146
153
|
},
|
|
147
154
|
"devDependencies": {
|
|
148
155
|
"@jest/globals": "^30.4.1",
|
|
149
156
|
"@types/node": "^25.9.0",
|
|
157
|
+
"acorn": "^8.18.0",
|
|
150
158
|
"chai": "^6.2.2",
|
|
151
159
|
"jest": "^30.4.2",
|
|
152
160
|
"mocha": "^11.8.0",
|