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/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
- if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
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<void>}
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 {{ uuid: string, to?: string | string[], tries: number, sendAt: number, isSent: boolean, isCancelled: boolean, isFailed: boolean, isSending?: boolean, sendingAt?: number, template?: string | false, transport: number, concatSubject?: string | false, mailOptions: MailTimeMailOptions[] }} MailTimeTask
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> }} CustomQueue
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: string | string[], sendAt?: Date | number, template?: string, concatSubject?: string, text?: string | false, html?: string | false, subject?: string, accepted?: string[], rejected?: MailTimeRejectedRecipient[] }} MailTimeMailOptions
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, onSent?: (email: MailTimeTask, details?: object) => void }} MailTimeOptions
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
- /** Class of MailTime */
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 }`, any in-flight SMTP attempts are neutralized on completion — they perform no storage writes, no `onSent`/`onError` callbacks, and no logging once `destroy()` returns; their claimed rows are recovered by stale-lock timeout (`sendingTimeout`). Pass `{ drain: true }` to instead let in-flight attempts run to completion (returns a Promise that resolves once they settle).
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
- if (this.scheduler && typeof this.scheduler.destroy === 'function') {
525
- this.scheduler.destroy();
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 (opts && opts.drain === true && this.__pool) {
528
- return this.__pool.drain().then(() => true);
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
- if (typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
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
- compiledOpts.html ??= '';
893
- compiledOpts.text ??= '';
894
- compiledOpts.subject ??= '';
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.__isDestroyed || this.__abortInFlight) {
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.__isDestroyed) {
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.__isDestroyed) {
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
- return await this.queue.iterate({
1448
- limit,
1449
- sendingTimeout: this.sendingTimeout,
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.0",
3
+ "version": "5.2.1",
4
4
  "description": "📮 Email queue extending nodemailer with multi-SMTP transports and horizontally scaled apps support",
5
5
  "main": "index.js",
6
6
  "types": "./index.d.ts",
@@ -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": ["2.14", "3.2"],
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.3.0"
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",