mail-time 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -208,11 +212,32 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
208
212
  */
209
213
 
210
214
  /**
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
215
+ * @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
216
+ */
217
+
218
+ /**
219
+ * @typedef {string | { address: string, name?: string }} MailTimeMailbox
212
220
  */
213
221
 
214
222
  /**
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
223
+ * @typedef {{ address: string, sources: Array<'envelope' | 'to' | 'cc' | 'bcc'> }} MailTimePolicyRecipient
224
+ * @typedef {{ address: string, status: 'suppressed' | 'rejected' | 'retry', reason: string }} MailTimePolicyDecision
225
+ * @typedef {{ decisions?: MailTimePolicyDecision[] }} MailTimePolicyResult
226
+ * @typedef {{ index: number, name?: string }} MailTimePolicyTransport
227
+ * @typedef {{ from?: string, to: string[] }} MailTimePolicyEnvelope
228
+ * @typedef {{ task: MailTimeTask, attempt: number, transport: MailTimePolicyTransport, envelope: MailTimePolicyEnvelope, recipients: MailTimePolicyRecipient[] }} MailTimePolicyContext
229
+ * @typedef {MailTimePolicyContext} MailTimeBeforeSendPolicyContext
230
+ * @typedef {{ address: string | null, command?: string, responseCode?: number, response?: string, message?: string, transportIndex: number, transportName?: string }} MailTimeStructuredRejection
231
+ * @typedef {MailTimePolicyContext & { error?: unknown, info?: unknown, rejections: MailTimeStructuredRejection[] }} MailTimeRejectionPolicyContext
232
+ * @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
233
+ * @typedef {MailTimeRejectionPolicyContext & { decisions: MailTimeRecipientResult[] }} MailTimeRecipientAttemptContext
234
+ * @typedef {{ uuid: string, tries: number, isSettled: boolean, recipients: { sent: MailTimeRecipientResult[], error: MailTimeRecipientResult[], suppressed: MailTimeRecipientResult[], rejected: MailTimeRecipientResult[] } }} MailTimeRecipientSummary
235
+ * @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
236
+ * @description Policy contexts are read-only by contract. Providers must bound their own I/O; renewal budgets do not limit hook duration.
237
+ */
238
+
239
+ /**
240
+ * @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
241
  */
217
242
 
218
243
  /**
@@ -220,7 +245,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
220
245
  */
221
246
 
222
247
  /**
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
248
+ * @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
249
  */
225
250
 
226
251
  /**
@@ -228,7 +253,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
228
253
  */
229
254
 
230
255
  /**
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
256
+ * @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
257
  */
233
258
 
234
259
  /**
@@ -240,10 +265,16 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
240
265
  */
241
266
 
242
267
  /**
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
268
+ * @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
269
  */
245
270
 
246
- /** Class of MailTime */
271
+ /**
272
+ * Class of MailTime.
273
+ * With recipientPolicies, terminal callbacks receive grouped recipients and a complete summary after persistence.
274
+ * isSettled includes exhausted errors; isSent means all accepted; isFailed means an error or permanent rejection.
275
+ * Unparseable terminal preparation failures invoke onError with an empty recipient group.
276
+ * Hooks must bound their own I/O. Callback notifications are best-effort, not durable exactly-once delivery.
277
+ */
247
278
  class MailTime {
248
279
  /**
249
280
  * Create a MailTime instance
@@ -258,6 +289,7 @@ class MailTime {
258
289
  throw new Error('[mail-time] {queue} option is required: provide a MongoQueue, RedisQueue, PostgresQueue, or CustomQueue instance');
259
290
  }
260
291
 
292
+ this.__recipientPolicies = validateRecipientPolicies(opts.recipientPolicies, opts.queue);
261
293
  this.queue = opts.queue;
262
294
 
263
295
  for (let i = queueMethods.length - 1; i >= 0; i--) {
@@ -297,6 +329,8 @@ class MailTime {
297
329
  this.keepHistory = opts.keepHistory === true;
298
330
  this.onSent = (typeof opts.onSent === 'function') ? opts.onSent.bind(this) : noop;
299
331
  this.onError = (typeof opts.onError === 'function') ? opts.onError.bind(this) : noop;
332
+ this.onSuppressed = (typeof opts.onSuppressed === 'function') ? opts.onSuppressed.bind(this) : noop;
333
+ this.onRejected = (typeof opts.onRejected === 'function') ? opts.onRejected.bind(this) : noop;
300
334
 
301
335
  this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
302
336
  this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
@@ -509,8 +543,8 @@ class MailTime {
509
543
  /**
510
544
  * @memberOf MailTime
511
545
  * @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]
546
+ * @description Stop the scheduler and block future dispatches. 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 a scheduler handler exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. The timeout does not bound SMTP drain time.
547
+ * @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
514
548
  * @returns {boolean | Promise<boolean>}
515
549
  */
516
550
  destroy(opts) {
@@ -518,14 +552,29 @@ class MailTime {
518
552
  if (this.__isDestroyed) {
519
553
  return false;
520
554
  }
555
+ if (opts?.drain === true && opts.schedulerTimeout !== void 0
556
+ && (typeof opts.schedulerTimeout !== 'number' || !Number.isFinite(opts.schedulerTimeout) || opts.schedulerTimeout < 0)) {
557
+ throw new Error('[mail-time] [destroy] schedulerTimeout must be a finite non-negative number');
558
+ }
521
559
 
522
560
  this.__isDestroyed = true;
523
561
  this.__isPaused = false;
524
- if (this.scheduler && typeof this.scheduler.destroy === 'function') {
525
- this.scheduler.destroy();
562
+ if (opts?.drain === true) {
563
+ return (async () => {
564
+ let finished = true;
565
+ try {
566
+ if (this.scheduler) finished = await this.scheduler.shutdown({ timeout: opts.schedulerTimeout });
567
+ } catch (error) {
568
+ logError('[destroy] scheduler shutdown failed', error);
569
+ finished = false;
570
+ } finally {
571
+ await this.__pool.drain();
572
+ }
573
+ return finished;
574
+ })();
526
575
  }
527
- if (opts && opts.drain === true && this.__pool) {
528
- return this.__pool.drain().then(() => true);
576
+ if (this.scheduler) {
577
+ this.scheduler.destroy();
529
578
  }
530
579
  // No graceful drain requested: neutralize in-flight SMTP completions so a
531
580
  // destroyed instance performs no storage writes, no `onSent`/`onError`
@@ -646,7 +695,8 @@ class MailTime {
646
695
  delete mailOptions.template;
647
696
  delete mailOptions.concatSubject;
648
697
 
649
- if (typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
698
+ const isMailbox = isPlainObject(mailOptions.to) && typeof mailOptions.to.address === 'string' && mailOptions.to.address.trim().length > 0;
699
+ if (!isMailbox && typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
650
700
  throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
651
701
  }
652
702
 
@@ -806,6 +856,7 @@ class MailTime {
806
856
  uuid: randomUUID(),
807
857
  tries: 0,
808
858
  isSent: false,
859
+ isSettled: false,
809
860
  sendAt: opts.sendAt,
810
861
  isFailed: false,
811
862
  isSending: false,
@@ -939,7 +990,7 @@ class MailTime {
939
990
  }
940
991
 
941
992
  const acceptedSet = collectAcceptedSet(task);
942
- if (acceptedSet.size > 0) {
993
+ if (!this.__recipientPolicies && acceptedSet.size > 0) {
943
994
  compiledOpts.to = filterAddressField(compiledOpts.to, acceptedSet);
944
995
  compiledOpts.cc = filterAddressField(compiledOpts.cc, acceptedSet);
945
996
  compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
@@ -1152,7 +1203,7 @@ class MailTime {
1152
1203
  };
1153
1204
 
1154
1205
  const renew = async () => {
1155
- if (stopped || this.__isDestroyed || this.__abortInFlight) {
1206
+ if (stopped || this.__abortInFlight) {
1156
1207
  halt();
1157
1208
  return;
1158
1209
  }
@@ -1205,6 +1256,204 @@ class MailTime {
1205
1256
  return { stop };
1206
1257
  }
1207
1258
 
1259
+ /** @internal Serialize every policy checkpoint with this attempt's claim renewal. */
1260
+ async ___sendWithRecipientPolicies(task) {
1261
+ if (!task || task.isSent || task.isFailed || task.isCancelled || task.isSettled || this.__abortInFlight) return;
1262
+ const recoveryOnly = Array.isArray(task.recipientResults) && (task.tries >= this.maxTries
1263
+ || (task.recipientResults.length > 0 && task.recipientResults.every((r) => r.status === 'sent' || r.status === 'suppressed' || r.status === 'rejected')));
1264
+ const fields = { tries: recoveryOnly ? task.tries : task.tries + 1, isSending: true, sendingAt: Math.max(Date.now(), (task.sendingAt || 0) + 1), recipientResults: task.recipientResults || [] };
1265
+ try {
1266
+ if (!await this.queue.update(task, fields)) return;
1267
+ } catch (error) {
1268
+ if (!this.__abortInFlight) logError('[recipientPolicies] claim failed', error);
1269
+ return;
1270
+ }
1271
+ Object.assign(task, fields);
1272
+ const lease = new RecipientPolicyLease({
1273
+ task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals,
1274
+ shouldAbort: () => this.__abortInFlight,
1275
+ report: (error) => logError('[recipientPolicies] lease persistence failed', error),
1276
+ });
1277
+ try {
1278
+ if (!lease.active) return;
1279
+ if (recoveryOnly) {
1280
+ let preparationFailure = false;
1281
+ if (!task.recipientResults.length) {
1282
+ try {
1283
+ const compiled = this.___compileMailOpts(this.transports[task.transport], task);
1284
+ const prepared = preparePolicyEnvelope(compiled);
1285
+ task.recipientResults = mergePolicyResults([], prepared.recipients, { retryFailure: false, decisions: [] }, {
1286
+ phase: 'beforeSend', attempt: task.tries, transportIndex: task.transport, accepted: [...collectAcceptedSet(task)],
1287
+ });
1288
+ } catch { preparationFailure = true; }
1289
+ }
1290
+ await this.___completePolicyTask(task, lease, policyError('last attempt interrupted; completing durable recipient state'), void 0, preparationFailure);
1291
+ return;
1292
+ }
1293
+ if (!this.___isHealthyTransport(task.transport)) task.transport = this.___nextHealthyTransport(task.transport);
1294
+ const transport = this.transports[task.transport];
1295
+ const compiled = this.___compileMailOpts(transport, task);
1296
+ if (!compiled.from) compiled.from = MailTime.transportFrom(transport);
1297
+ const prepared = preparePolicyEnvelope(compiled, task.recipientResults);
1298
+ const transportName = typeof transport.name === 'string' ? transport.name : transport.options?.name;
1299
+ const details = { phase: 'beforeSend', attempt: task.tries, transportIndex: task.transport, transportName, accepted: [...collectAcceptedSet(task)] };
1300
+ task.recipientResults = mergePolicyResults(task.recipientResults, prepared.recipients, { retryFailure: false, decisions: [] }, details);
1301
+ const pending = new Set(task.recipientResults.filter((r) => r.status === 'error').map((r) => r.address));
1302
+ const context = {
1303
+ task, attempt: task.tries, transport: { index: task.transport, ...(typeof transportName === 'string' ? { name: transportName.slice(0, 128) } : {}) },
1304
+ envelope: { ...prepared.envelope, to: prepared.envelope.to.filter((a) => pending.has(a)) },
1305
+ recipients: prepared.recipients.filter((r) => pending.has(r.address)),
1306
+ };
1307
+ if (!pending.size) {
1308
+ await this.___completePolicyTask(task, lease);
1309
+ return;
1310
+ }
1311
+ const evaluated = await evaluatePolicyPhase(this.__recipientPolicies, 'beforeSend', context, (error, name, hook) => {
1312
+ if (!this.__abortInFlight) logError(`[recipientPolicies] ${name}.${hook} failed`, error);
1313
+ });
1314
+ if (!lease.active) return;
1315
+ task.recipientResults = mergePolicyResults(task.recipientResults, context.recipients, evaluated, details);
1316
+ if (!await lease.update({ recipientResults: task.recipientResults, mailOptions: task.mailOptions })) return;
1317
+ if (!lease.active) return;
1318
+ if (evaluated.retryFailure) {
1319
+ await this.___retryPolicyTask(task, lease, policyError('beforeSend provider failed'), void 0, true);
1320
+ return;
1321
+ }
1322
+ const eligible = new Set(task.recipientResults.filter((r) => r.status === 'error').map((r) => r.address));
1323
+ if (!eligible.size) {
1324
+ await this.___completePolicyTask(task, lease);
1325
+ return;
1326
+ }
1327
+ context.envelope = { ...context.envelope, to: context.envelope.to.filter((a) => eligible.has(a)) };
1328
+ context.recipients = context.recipients.filter((r) => eligible.has(r.address));
1329
+ await this.___attemptPolicyTransport(task, lease, compiled, context);
1330
+ } catch (error) {
1331
+ if (lease.active) await this.___retryPolicyTask(task, lease, error, void 0, true, true);
1332
+ } finally {
1333
+ await lease.stop();
1334
+ }
1335
+ }
1336
+
1337
+ /** @internal Persist SMTP acceptance before awaiting rejection classification or observation. */
1338
+ async ___attemptPolicyTransport(task, lease, compiled, context) {
1339
+ const transport = this.transports[context.transport.index];
1340
+ const outgoing = { ...compiled, envelope: context.envelope };
1341
+ const { error, info } = await new Promise((resolve) => {
1342
+ let called = false;
1343
+ const done = (error, info) => {
1344
+ if (called) return;
1345
+ called = true;
1346
+ resolve({ error, info });
1347
+ };
1348
+ try { transport.sendMail(outgoing, done); }
1349
+ catch (error) { done(error, void 0); }
1350
+ });
1351
+ if (!lease.active) return;
1352
+ const attempted = new Set(context.envelope.to);
1353
+ const accepted = new Set();
1354
+ for (const source of [info, error]) {
1355
+ for (const value of Array.isArray(source?.accepted) ? source.accepted : []) {
1356
+ try {
1357
+ const address = normalizePolicyAddress(value);
1358
+ if (attempted.has(address)) accepted.add(address);
1359
+ } catch { /* Transport records outside supported address forms remain unresolved. */ }
1360
+ }
1361
+ }
1362
+ const details = { phase: 'classifyRejections', attempt: task.tries, transportIndex: context.transport.index, transportName: context.transport.name, accepted: [...accepted] };
1363
+ task.recipientResults = mergePolicyResults(task.recipientResults, context.recipients, { retryFailure: false, decisions: [] }, details);
1364
+ this.___mapPolicyMailOptions(task, false);
1365
+ if (!await lease.update({ recipientResults: task.recipientResults, mailOptions: task.mailOptions })) return;
1366
+ if (!lease.active) return;
1367
+ const unresolved = context.recipients.filter((r) => !accepted.has(r.address));
1368
+ if (unresolved.length) {
1369
+ const rejections = normalizeRejections(error, info, context.transport);
1370
+ const rejectionContext = { ...context, recipients: unresolved, error, info, rejections };
1371
+ const evaluated = await evaluatePolicyPhase(this.__recipientPolicies, 'classifyRejections', rejectionContext, (hookError, name, hook) => {
1372
+ if (!this.__abortInFlight) logError(`[recipientPolicies] ${name}.${hook} failed`, hookError);
1373
+ });
1374
+ if (!lease.active) return;
1375
+ task.recipientResults = mergePolicyResults(task.recipientResults, unresolved, evaluated, { ...details, rejections });
1376
+ const observation = { ...rejectionContext, decisions: task.recipientResults };
1377
+ for (const provider of this.__recipientPolicies) {
1378
+ if (!lease.active) return;
1379
+ if (typeof provider.observeAttempt !== 'function') continue;
1380
+ try { await provider.observeAttempt(observation); }
1381
+ catch (hookError) {
1382
+ if (!this.__abortInFlight) logError(`[recipientPolicies] ${provider.name}.observeAttempt failed`, hookError);
1383
+ }
1384
+ }
1385
+ }
1386
+ if (!lease.active) return;
1387
+ if (task.recipientResults.some((r) => r.status === 'error')) {
1388
+ await this.___retryPolicyTask(task, lease, error || policyError('recipients remain unaccepted'), info, !!error || accepted.size === 0);
1389
+ } else {
1390
+ await this.___completePolicyTask(task, lease, error, info);
1391
+ }
1392
+ }
1393
+
1394
+ /** @internal Keep legacy per-mail-option state limited to actual envelope recipients. */
1395
+ ___mapPolicyMailOptions(task, terminal) {
1396
+ const results = new Map(task.recipientResults.map((r) => [r.address, r]));
1397
+ for (const option of task.mailOptions || []) {
1398
+ const addresses = new Set();
1399
+ for (const field of ['to', 'cc', 'bcc']) {
1400
+ const values = Array.isArray(option[field]) ? option[field] : [option[field]];
1401
+ for (const value of values) {
1402
+ try { addresses.add(normalizePolicyAddress(value)); } catch { /* Explicit envelopes permit complex headers. */ }
1403
+ }
1404
+ }
1405
+ const accepted = new Set(Array.isArray(option.accepted) ? option.accepted : []);
1406
+ const rejected = [];
1407
+ for (const address of addresses) {
1408
+ const result = results.get(address);
1409
+ if (result?.status === 'sent') accepted.add(address);
1410
+ if (terminal && (result?.status === 'error' || result?.status === 'rejected')) {
1411
+ rejected.push({ address, error: result.message || result.response || result.reasons[0]?.reason || 'Recipient not accepted by transport' });
1412
+ }
1413
+ }
1414
+ option.accepted = [...accepted];
1415
+ if (terminal) option.rejected = rejected;
1416
+ }
1417
+ }
1418
+
1419
+ /** @internal Release a policy claim with all durable outcomes, or settle at exhaustion. */
1420
+ async ___retryPolicyTask(task, lease, error, info, allowRotation, preparationFailure = false) {
1421
+ if (!lease.active) return false;
1422
+ if (task.tries >= this.maxTries) return await this.___completePolicyTask(task, lease, error, info, preparationFailure);
1423
+ let transport = task.transport;
1424
+ if (allowRotation && this.strategy === 'backup' && this.transports.length > 1
1425
+ && task.tries % this.failsToNext === 0 && this.___mayFailOver(error, info, task)) transport = this.___nextHealthyTransport(transport);
1426
+ return await lease.finish({ isSending: false, sendingAt: 0, sendAt: Date.now() + this.retryDelay, transport,
1427
+ recipientResults: task.recipientResults, mailOptions: task.mailOptions }, false);
1428
+ }
1429
+
1430
+ /** @internal Settle truthfully before invoking any best-effort terminal notification. */
1431
+ async ___completePolicyTask(task, lease, error, info, preparationFailure = false) {
1432
+ if (!lease.active) return false;
1433
+ this.___mapPolicyMailOptions(task, true);
1434
+ const results = task.recipientResults;
1435
+ const fields = {
1436
+ isSettled: true, isSent: results.length > 0 && results.every((r) => r.status === 'sent'),
1437
+ isFailed: preparationFailure || results.some((r) => r.status === 'error' || r.status === 'rejected'),
1438
+ isSending: false, sendingAt: 0, recipientResults: results, mailOptions: task.mailOptions,
1439
+ };
1440
+ const completed = await lease.finish(fields, !this.keepHistory);
1441
+ if (completed && !this.__abortInFlight) this.___notifyPolicyGroups(task, error, info, preparationFailure);
1442
+ return completed;
1443
+ }
1444
+
1445
+ /** @internal Empty groups are silent except unparseable terminal preparation failures. */
1446
+ ___notifyPolicyGroups(task, error, info, preparationFailure) {
1447
+ const summary = summarizePolicyTask(task, true);
1448
+ const groups = summary.recipients;
1449
+ if (groups.sent.length) callHook('onSent', this.onSent, task, info, groups.sent, summary);
1450
+ if (!this.__abortInFlight && (groups.error.length || (preparationFailure && !task.recipientResults.length))) {
1451
+ callHook('onError', this.onError, error || policyError('recipients remain unaccepted'), task, info, groups.error, summary);
1452
+ }
1453
+ if (!this.__abortInFlight && groups.suppressed.length) callHook('onSuppressed', this.onSuppressed, task, groups.suppressed, summary);
1454
+ if (!this.__abortInFlight && groups.rejected.length) callHook('onRejected', this.onRejected, task, groups.rejected, summary);
1455
+ }
1456
+
1208
1457
  /**
1209
1458
  * @async
1210
1459
  * @internal
@@ -1248,6 +1497,12 @@ class MailTime {
1248
1497
  * @returns {Promise<void 0>}
1249
1498
  */
1250
1499
  async ___send(task) {
1500
+ if (this.__recipientPolicies) return await this.___sendWithRecipientPolicies(task);
1501
+ if (task?.isSettled === true) return;
1502
+ if (Array.isArray(task?.recipientResults)) {
1503
+ logError('[recipientPolicies] active policy state requires a policy-configured worker', task.uuid);
1504
+ return;
1505
+ }
1251
1506
  this.__debug('[private send]', task);
1252
1507
  try {
1253
1508
  if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mail-time",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
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,14 +139,17 @@
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",
@@ -0,0 +1,90 @@
1
+ class RecipientPolicyLease {
2
+ constructor({ task, queue, interval, maxRenewals, shouldAbort, report }) {
3
+ this.__task = task;
4
+ this.__queue = queue;
5
+ this.__shouldAbort = shouldAbort;
6
+ this.__report = report;
7
+ this.__closed = false;
8
+ this.__finishing = false;
9
+ this.__tail = Promise.resolve();
10
+ this.__timer = null;
11
+ let pending = false;
12
+ let renewals = 0;
13
+ if (interval && maxRenewals) {
14
+ this.__timer = setInterval(() => {
15
+ if (!this.active || this.__finishing || renewals >= maxRenewals) {
16
+ this.__clearTimer();
17
+ return;
18
+ }
19
+ if (pending) return;
20
+ pending = true;
21
+ renewals++;
22
+ this.__enqueue(async (guard) => {
23
+ const fields = { isSending: true, sendingAt: Math.max(Date.now(), this.__task.sendingAt + 1) };
24
+ return await this.__write(fields, guard);
25
+ }).finally(() => { pending = false; });
26
+ }, interval);
27
+ this.__timer.unref?.();
28
+ }
29
+ }
30
+
31
+ get active() { return !this.__closed && !this.__shouldAbort(); }
32
+
33
+ __clearTimer() {
34
+ if (this.__timer) clearInterval(this.__timer);
35
+ this.__timer = null;
36
+ }
37
+
38
+ __halt() {
39
+ this.__clearTimer();
40
+ this.__closed = true;
41
+ }
42
+
43
+ __enqueue(operation) {
44
+ const pending = this.__tail.then(async () => {
45
+ if (!this.active) return false;
46
+ const guard = { leaseTries: this.__task.tries, leaseSendingAt: this.__task.sendingAt };
47
+ try {
48
+ const ok = await operation(guard);
49
+ if (!ok) this.__halt();
50
+ return ok;
51
+ } catch (error) {
52
+ this.__halt();
53
+ if (!this.__shouldAbort()) this.__report(error);
54
+ return false;
55
+ }
56
+ });
57
+ this.__tail = pending.then(() => void 0);
58
+ return pending;
59
+ }
60
+
61
+ async __write(fields, guard) {
62
+ const ok = await this.__queue.update(this.__task, { ...fields, ...guard });
63
+ if (ok) Object.assign(this.__task, fields);
64
+ return ok;
65
+ }
66
+
67
+ update(fields) {
68
+ if (this.__finishing) return Promise.resolve(false);
69
+ return this.__enqueue((guard) => this.__write(fields, guard));
70
+ }
71
+
72
+ finish(fields, remove) {
73
+ if (this.__finishing) return Promise.resolve(false);
74
+ this.__finishing = true;
75
+ this.__clearTimer();
76
+ return this.__enqueue(async (guard) => {
77
+ const ok = remove ? await this.__queue.remove(this.__task, guard) : await this.__queue.update(this.__task, { ...fields, ...guard });
78
+ if (ok) Object.assign(this.__task, fields);
79
+ this.__halt();
80
+ return ok;
81
+ });
82
+ }
83
+
84
+ async stop() {
85
+ this.__halt();
86
+ await this.__tail;
87
+ }
88
+ }
89
+
90
+ export { RecipientPolicyLease };