mail-time 5.1.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -14
- package/adapters/mongo.d.ts +1 -0
- package/adapters/mongo.js +34 -16
- package/adapters/postgres.d.ts +1 -0
- package/adapters/postgres.js +45 -24
- package/adapters/redis.d.ts +6 -2
- package/adapters/redis.js +153 -72
- package/index.cjs +805 -127
- package/index.d.cts +136 -14
- package/index.d.ts +136 -14
- package/index.js +270 -15
- package/package.json +10 -3
- 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
|
|
|
@@ -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 {{
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
/**
|
|
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 }`,
|
|
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 (
|
|
525
|
-
|
|
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 (
|
|
528
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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": [
|
|
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",
|
|
@@ -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 };
|