mail-time 5.2.0 → 5.3.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 +22 -11
- package/adapters/mongo.js +1 -1
- package/adapters/postgres.js +3 -0
- package/adapters/redis.js +6 -0
- package/index.cjs +556 -141
- package/index.d.cts +21 -77
- package/index.d.ts +21 -77
- package/index.js +342 -99
- package/package.json +5 -4
- package/presets.js +15 -15
- package/recipient-policy-lease.js +40 -8
- package/recipient-policy.js +151 -20
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@ 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';
|
|
10
|
+
import { validateRecipientPolicies, validatePolicyMailOptions, policyError, isAddressError, normalizePolicyAddress, rewritePolicyHeaders, preparePolicyEnvelope, evaluatePolicyPhase, mergePolicyResults, summarizePolicyTask } from './recipient-policy.js';
|
|
11
11
|
import { normalizeRejections } from './recipient-rejections.js';
|
|
12
12
|
import { RecipientPolicyLease } from './recipient-policy-lease.js';
|
|
13
13
|
|
|
@@ -79,11 +79,19 @@ const createPool = (concurrency) => {
|
|
|
79
79
|
let active = 0;
|
|
80
80
|
let drainResolvers = [];
|
|
81
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
|
+
|
|
82
90
|
const tryStart = () => {
|
|
83
91
|
while (active < limit && queue.length > 0) {
|
|
84
92
|
const job = queue.shift();
|
|
85
93
|
active++;
|
|
86
|
-
job.resolveSlot();
|
|
94
|
+
job.resolveSlot(true);
|
|
87
95
|
Promise.resolve()
|
|
88
96
|
.then(job.fn)
|
|
89
97
|
.catch((poolError) => {
|
|
@@ -91,11 +99,7 @@ const createPool = (concurrency) => {
|
|
|
91
99
|
})
|
|
92
100
|
.finally(() => {
|
|
93
101
|
active--;
|
|
94
|
-
|
|
95
|
-
const resolvers = drainResolvers;
|
|
96
|
-
drainResolvers = [];
|
|
97
|
-
for (const r of resolvers) r();
|
|
98
|
-
}
|
|
102
|
+
settleDrain();
|
|
99
103
|
tryStart();
|
|
100
104
|
});
|
|
101
105
|
}
|
|
@@ -104,10 +108,11 @@ const createPool = (concurrency) => {
|
|
|
104
108
|
return {
|
|
105
109
|
/**
|
|
106
110
|
* Queue `fn` for execution under the concurrency limit.
|
|
107
|
-
* 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).
|
|
108
113
|
* It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
|
|
109
114
|
* @param {() => Promise<void>} fn
|
|
110
|
-
* @returns {Promise<
|
|
115
|
+
* @returns {Promise<boolean>}
|
|
111
116
|
*/
|
|
112
117
|
dispatch(fn) {
|
|
113
118
|
return new Promise((resolveSlot) => {
|
|
@@ -115,6 +120,16 @@ const createPool = (concurrency) => {
|
|
|
115
120
|
tryStart();
|
|
116
121
|
});
|
|
117
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
|
+
},
|
|
118
133
|
drain() {
|
|
119
134
|
if (active === 0 && queue.length === 0) {
|
|
120
135
|
return Promise.resolve();
|
|
@@ -265,9 +280,21 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
265
280
|
*/
|
|
266
281
|
|
|
267
282
|
/**
|
|
268
|
-
* @typedef {{
|
|
283
|
+
* @typedef {{ failedWrites: number }} MailTimeDrainResult
|
|
269
284
|
*/
|
|
270
285
|
|
|
286
|
+
/**
|
|
287
|
+
* `details` passed to `onError`. `phase` is `'verify'` (transport verification, `email === null`), `'complete'` (a storage write recording a send outcome), or `'checkpoint'` (recipient-policy results written after SMTP). Without `phase` it is the SMTP `info` of the failed attempt.
|
|
288
|
+
* @typedef {{ phase?: 'verify' | 'complete' | 'checkpoint', transportIndex?: number, attempt?: number, [key: string]: unknown }} MailTimeErrorDetails
|
|
289
|
+
*/
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* @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, verifyTimeout?: number, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, recipientPolicies?: MailTimeRecipientPolicy[], onError?: (error: unknown, email: MailTimeTask | null, details?: MailTimeErrorDetails, 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
|
|
293
|
+
*/
|
|
294
|
+
|
|
295
|
+
const REPROBE_BASE_MS = 60000;
|
|
296
|
+
const REPROBE_MAX_MS = 900000;
|
|
297
|
+
|
|
271
298
|
/**
|
|
272
299
|
* Class of MailTime.
|
|
273
300
|
* With recipientPolicies, terminal callbacks receive grouped recipients and a complete summary after persistence.
|
|
@@ -366,9 +393,11 @@ class MailTime {
|
|
|
366
393
|
this.__isDestroyed = false;
|
|
367
394
|
this.__abortInFlight = false;
|
|
368
395
|
this.__isPaused = false;
|
|
396
|
+
this.__schedulerScans = 0;
|
|
369
397
|
this.__readyPromise = null;
|
|
370
398
|
this.__schedulerTimer = null;
|
|
371
399
|
this.__inFlight = new Set();
|
|
400
|
+
this.__failedWrites = 0;
|
|
372
401
|
this.__pool = createPool(this.concurrency);
|
|
373
402
|
|
|
374
403
|
this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
|
|
@@ -376,7 +405,12 @@ class MailTime {
|
|
|
376
405
|
this.transports = Array.isArray(opts.transports) ? opts.transports : [];
|
|
377
406
|
this.transport = 0;
|
|
378
407
|
this.verifyTransports = opts.verifyTransports !== false;
|
|
408
|
+
this.verifyTimeout = (typeof opts.verifyTimeout === 'number' && opts.verifyTimeout > 0) ? Math.min(opts.verifyTimeout, 2147483647) : 30000;
|
|
379
409
|
this.__unhealthyTransports = new Set();
|
|
410
|
+
this.__verifyGen = [];
|
|
411
|
+
this.__reprobe = new Map();
|
|
412
|
+
this.__probeCancels = new Set();
|
|
413
|
+
this.__uncertainRenewals = new WeakMap();
|
|
380
414
|
|
|
381
415
|
if (typeof opts.from === 'string') {
|
|
382
416
|
const fromStr = opts.from;
|
|
@@ -387,6 +421,16 @@ class MailTime {
|
|
|
387
421
|
this.from = false;
|
|
388
422
|
}
|
|
389
423
|
|
|
424
|
+
if (this.__recipientPolicies && this.type === 'server') {
|
|
425
|
+
// A transport `from` that the policy parser rejects would settle every rotated letter as
|
|
426
|
+
// failed; surface it here instead. A `from` callback is a runtime value and is not checked.
|
|
427
|
+
if (typeof opts.from === 'string') normalizePolicyAddress(opts.from, 'from');
|
|
428
|
+
this.transports.forEach((transport, i) => {
|
|
429
|
+
const from = MailTime.transportFrom(transport);
|
|
430
|
+
if (from !== void 0) normalizePolicyAddress(from, `transports[${i}].from`);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
390
434
|
this.queue.mailTimeInstance = this;
|
|
391
435
|
|
|
392
436
|
/** @type {string} */
|
|
@@ -543,7 +587,7 @@ class MailTime {
|
|
|
543
587
|
/**
|
|
544
588
|
* @memberOf MailTime
|
|
545
589
|
* @name destroy
|
|
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
|
|
590
|
+
* @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.
|
|
547
591
|
* @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
|
|
548
592
|
* @returns {boolean | Promise<boolean>}
|
|
549
593
|
*/
|
|
@@ -559,6 +603,11 @@ class MailTime {
|
|
|
559
603
|
|
|
560
604
|
this.__isDestroyed = true;
|
|
561
605
|
this.__isPaused = false;
|
|
606
|
+
for (const cancel of [...this.__probeCancels]) cancel();
|
|
607
|
+
this.__reprobe.clear();
|
|
608
|
+
// Queued jobs never started SMTP; dropping them lets a scan blocked on a
|
|
609
|
+
// pool slot return, so JoSk shutdown does not wait for in-flight sends.
|
|
610
|
+
this.__pool.cancelQueued();
|
|
562
611
|
if (opts?.drain === true) {
|
|
563
612
|
return (async () => {
|
|
564
613
|
let finished = true;
|
|
@@ -591,20 +640,40 @@ class MailTime {
|
|
|
591
640
|
* @async
|
|
592
641
|
* @memberOf MailTime
|
|
593
642
|
* @name drain
|
|
594
|
-
* @description Wait for all in-flight email send attempts to settle
|
|
595
|
-
* @returns {Promise<
|
|
643
|
+
* @description Wait for all in-flight email send attempts to settle. Resolves with `{ failedWrites }`, the count since this instance was created of failures while recording a send outcome: a storage write that threw, an exception in the completion path, or an outcome write lost after a claim-renewal error (message `outcome write lost (renewal outcome uncertain or lease taken over)`, after one retry with the renewal's stamp (no retry when the claim was already stale at renewal time)). Each is also reported once through `onError` with `details.phase` of `'complete'` or `'checkpoint'`, except after a plain `destroy()`, which still counts but suppresses the hook and the log. A row whose write failed can stay `sending` and be re-sent after `sendingTimeout`. Compare `failedWrites` before and after to scope a shutdown.
|
|
644
|
+
* @returns {Promise<MailTimeDrainResult>}
|
|
596
645
|
*/
|
|
597
646
|
async drain() {
|
|
598
647
|
this.__debug('[drain]');
|
|
599
648
|
if (this.__pool) {
|
|
600
649
|
await this.__pool.drain();
|
|
601
650
|
}
|
|
651
|
+
return { failedWrites: this.__failedWrites };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* @internal
|
|
656
|
+
* @memberOf MailTime
|
|
657
|
+
* @name ___reportWriteFailure
|
|
658
|
+
* @description Count a failed completion step (a storage write that threw, an exception raised inside the completion path, or an outcome write lost after a claim-renewal error) and surface it once through `onError(error, task, { phase })`. Never throws. After a non-draining `destroy()` it still counts but skips the log and the hook.
|
|
659
|
+
* @param {MailTimeTask} task
|
|
660
|
+
* @param {unknown} error
|
|
661
|
+
* @param {'complete' | 'checkpoint'} [phase]
|
|
662
|
+
* @returns {void}
|
|
663
|
+
*/
|
|
664
|
+
___reportWriteFailure(task, error, phase = 'complete') {
|
|
665
|
+
this.__failedWrites++;
|
|
666
|
+
if (this.__abortInFlight) {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
logError(`[private send] completion error, ${phase} write failed; row ${task?.uuid} may stay claimed and be re-sent after sendingTimeout`, error);
|
|
670
|
+
callHook('onError', this.onError, error, task || null, { phase, attempt: task?.tries, transportIndex: task?.transport });
|
|
602
671
|
}
|
|
603
672
|
|
|
604
673
|
/**
|
|
605
674
|
* @memberOf MailTime
|
|
606
675
|
* @name pause
|
|
607
|
-
* @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();`.
|
|
676
|
+
* @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();`.
|
|
608
677
|
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
609
678
|
*/
|
|
610
679
|
pause() {
|
|
@@ -615,6 +684,9 @@ class MailTime {
|
|
|
615
684
|
const paused = this.scheduler.pause();
|
|
616
685
|
if (paused) {
|
|
617
686
|
this.__isPaused = true;
|
|
687
|
+
if (this.__schedulerScans > 0) {
|
|
688
|
+
this.__pool.cancelQueued();
|
|
689
|
+
}
|
|
618
690
|
}
|
|
619
691
|
return paused;
|
|
620
692
|
}
|
|
@@ -700,6 +772,10 @@ class MailTime {
|
|
|
700
772
|
throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
|
|
701
773
|
}
|
|
702
774
|
|
|
775
|
+
if (this.__recipientPolicies) {
|
|
776
|
+
validatePolicyMailOptions(mailOptions);
|
|
777
|
+
}
|
|
778
|
+
|
|
703
779
|
if (this.concatEmails) {
|
|
704
780
|
sendAt = sendAt + this.concatDelay;
|
|
705
781
|
const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
|
|
@@ -781,21 +857,15 @@ class MailTime {
|
|
|
781
857
|
if (task.tries >= this.maxTries) {
|
|
782
858
|
this.___finalizeRejected(task, info);
|
|
783
859
|
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
let finalized = false;
|
|
794
|
-
if (!this.keepHistory) {
|
|
795
|
-
finalized = await this.queue.remove(task, leaseRemove);
|
|
796
|
-
} else {
|
|
797
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
798
|
-
}
|
|
860
|
+
const finalized = await this.___outcomeWrite(task, (t) => (!this.keepHistory
|
|
861
|
+
? this.queue.remove(t, __leaseRemoveOpts(t))
|
|
862
|
+
: this.queue.update(t, __withLease(t, {
|
|
863
|
+
isSent: false,
|
|
864
|
+
isFailed: true,
|
|
865
|
+
isSending: false,
|
|
866
|
+
sendingAt: 0,
|
|
867
|
+
mailOptions: task.mailOptions,
|
|
868
|
+
}))));
|
|
799
869
|
|
|
800
870
|
if (!finalized) {
|
|
801
871
|
this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
|
|
@@ -821,12 +891,12 @@ class MailTime {
|
|
|
821
891
|
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
822
892
|
}
|
|
823
893
|
|
|
824
|
-
const released = await this.queue.update(
|
|
894
|
+
const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
|
|
825
895
|
isSending: false,
|
|
826
896
|
sendingAt: 0,
|
|
827
897
|
sendAt: Date.now() + this.retryDelay,
|
|
828
898
|
transport: transportIndex,
|
|
829
|
-
}));
|
|
899
|
+
})));
|
|
830
900
|
|
|
831
901
|
if (!released) {
|
|
832
902
|
this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
|
|
@@ -940,9 +1010,11 @@ class MailTime {
|
|
|
940
1010
|
compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
|
|
941
1011
|
}
|
|
942
1012
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1013
|
+
for (const field of ['html', 'text', 'subject']) {
|
|
1014
|
+
if (compiledOpts[field] === void 0 || compiledOpts[field] === null) {
|
|
1015
|
+
compiledOpts[field] = '';
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
946
1018
|
|
|
947
1019
|
const mailOptionsList = task.mailOptions || [];
|
|
948
1020
|
const isMulti = mailOptionsList.length > 1;
|
|
@@ -1226,6 +1298,10 @@ class MailTime {
|
|
|
1226
1298
|
}));
|
|
1227
1299
|
} catch (renewError) {
|
|
1228
1300
|
logError('[private renewClaim] storage error during claim renewal', renewError);
|
|
1301
|
+
// Storage may have applied the write before the driver failed; remember the stamp
|
|
1302
|
+
// so the outcome write can retry with it (see ___outcomeWrite).
|
|
1303
|
+
// A stale claim is remembered as null: the lost outcome write is reported, never retried.
|
|
1304
|
+
this.__uncertainRenewals.set(task, task.sendingAt > renewedAt - this.sendingTimeout ? renewedAt : null);
|
|
1229
1305
|
halt();
|
|
1230
1306
|
return;
|
|
1231
1307
|
}
|
|
@@ -1256,6 +1332,38 @@ class MailTime {
|
|
|
1256
1332
|
return { stop };
|
|
1257
1333
|
}
|
|
1258
1334
|
|
|
1335
|
+
/**
|
|
1336
|
+
* @async
|
|
1337
|
+
* @internal
|
|
1338
|
+
* @memberOf MailTime
|
|
1339
|
+
* @name ___outcomeWrite
|
|
1340
|
+
* @description Run a lease-guarded outcome write. `write(t)` must derive its lease guard from `t`. When the write loses the lease and an earlier claim renewal threw (storage may have applied it), retry once with that renewal's stamp; a peer takeover bumps `tries` or stamps a later `sendingAt` while the lease is live, so the retry cannot match a taken-over row. The stamp is only remembered when the lease was still live at renewal time. If the retry also fails, count and report the lost write. A claim that was already stale when the renewal threw is reported without a retry. After `destroy()` the loss is counted without hook or log. Without an earlier renewal error a lost lease stays a debug-only skip.
|
|
1341
|
+
* @param {MailTimeTask} task
|
|
1342
|
+
* @param {(t: MailTimeTask) => Promise<boolean>} write
|
|
1343
|
+
* @returns {Promise<boolean>}
|
|
1344
|
+
*/
|
|
1345
|
+
async ___outcomeWrite(task, write) {
|
|
1346
|
+
if (await write(task)) {
|
|
1347
|
+
this.__uncertainRenewals.delete(task);
|
|
1348
|
+
return true;
|
|
1349
|
+
}
|
|
1350
|
+
const attempted = this.__uncertainRenewals.get(task);
|
|
1351
|
+
if (attempted === void 0) {
|
|
1352
|
+
return false;
|
|
1353
|
+
}
|
|
1354
|
+
this.__uncertainRenewals.delete(task);
|
|
1355
|
+
const lost = new Error('outcome write lost (renewal outcome uncertain or lease taken over)');
|
|
1356
|
+
if (attempted === null || this.__abortInFlight) {
|
|
1357
|
+
this.___reportWriteFailure(task, lost, 'complete');
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
if (await write({ ...task, sendingAt: attempted })) {
|
|
1361
|
+
return true;
|
|
1362
|
+
}
|
|
1363
|
+
this.___reportWriteFailure(task, lost, 'complete');
|
|
1364
|
+
return false;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1259
1367
|
/** @internal Serialize every policy checkpoint with this attempt's claim renewal. */
|
|
1260
1368
|
async ___sendWithRecipientPolicies(task) {
|
|
1261
1369
|
if (!task || task.isSent || task.isFailed || task.isCancelled || task.isSettled || this.__abortInFlight) return;
|
|
@@ -1270,9 +1378,12 @@ class MailTime {
|
|
|
1270
1378
|
}
|
|
1271
1379
|
Object.assign(task, fields);
|
|
1272
1380
|
const lease = new RecipientPolicyLease({
|
|
1273
|
-
task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals,
|
|
1381
|
+
task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals, sendingTimeout: this.sendingTimeout,
|
|
1274
1382
|
shouldAbort: () => this.__abortInFlight,
|
|
1275
|
-
report: (error) =>
|
|
1383
|
+
report: (error, phase) => {
|
|
1384
|
+
if (phase === 'complete' || phase === 'checkpoint') this.___reportWriteFailure(task, error, phase);
|
|
1385
|
+
else if (!this.__abortInFlight) logError('[recipientPolicies] claim renewal write failed; renewals stop, the outcome write still runs', error);
|
|
1386
|
+
},
|
|
1276
1387
|
});
|
|
1277
1388
|
try {
|
|
1278
1389
|
if (!lease.active) return;
|
|
@@ -1328,7 +1439,15 @@ class MailTime {
|
|
|
1328
1439
|
context.recipients = context.recipients.filter((r) => eligible.has(r.address));
|
|
1329
1440
|
await this.___attemptPolicyTransport(task, lease, compiled, context);
|
|
1330
1441
|
} catch (error) {
|
|
1331
|
-
if (lease.active)
|
|
1442
|
+
if (!lease.active) return;
|
|
1443
|
+
if (isAddressError(error)) {
|
|
1444
|
+
// Unparseable addresses fail identically on every attempt, so settle now instead of
|
|
1445
|
+
// spending the retry budget. The message names the field, never the address.
|
|
1446
|
+
if (!this.__abortInFlight) logError(`[recipientPolicies] task ${task.uuid} failed without retry:`, error.message);
|
|
1447
|
+
await this.___completePolicyTask(task, lease, error, void 0, true);
|
|
1448
|
+
} else {
|
|
1449
|
+
await this.___retryPolicyTask(task, lease, error, void 0, true, true);
|
|
1450
|
+
}
|
|
1332
1451
|
} finally {
|
|
1333
1452
|
await lease.stop();
|
|
1334
1453
|
}
|
|
@@ -1337,7 +1456,7 @@ class MailTime {
|
|
|
1337
1456
|
/** @internal Persist SMTP acceptance before awaiting rejection classification or observation. */
|
|
1338
1457
|
async ___attemptPolicyTransport(task, lease, compiled, context) {
|
|
1339
1458
|
const transport = this.transports[context.transport.index];
|
|
1340
|
-
const outgoing = { ...compiled, envelope: context.envelope };
|
|
1459
|
+
const outgoing = { ...rewritePolicyHeaders(compiled), envelope: context.envelope };
|
|
1341
1460
|
const { error, info } = await new Promise((resolve) => {
|
|
1342
1461
|
let called = false;
|
|
1343
1462
|
const done = (error, info) => {
|
|
@@ -1459,12 +1578,12 @@ class MailTime {
|
|
|
1459
1578
|
* @internal
|
|
1460
1579
|
* @memberOf MailTime
|
|
1461
1580
|
* @name ___dispatch
|
|
1462
|
-
* @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.
|
|
1581
|
+
* @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.
|
|
1463
1582
|
* @param {MailTimeTask} task - email's task object from Storage
|
|
1464
1583
|
* @returns {Promise<void 0>}
|
|
1465
1584
|
*/
|
|
1466
1585
|
async ___dispatch(task) {
|
|
1467
|
-
if (this.
|
|
1586
|
+
if (this.___isStopped) {
|
|
1468
1587
|
return;
|
|
1469
1588
|
}
|
|
1470
1589
|
if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
@@ -1475,9 +1594,9 @@ class MailTime {
|
|
|
1475
1594
|
return;
|
|
1476
1595
|
}
|
|
1477
1596
|
this.__inFlight.add(task.uuid);
|
|
1478
|
-
await this.__pool.dispatch(async () => {
|
|
1597
|
+
const started = await this.__pool.dispatch(async () => {
|
|
1479
1598
|
try {
|
|
1480
|
-
if (this.
|
|
1599
|
+
if (this.___isStopped) {
|
|
1481
1600
|
return;
|
|
1482
1601
|
}
|
|
1483
1602
|
await this.___send(task);
|
|
@@ -1485,6 +1604,20 @@ class MailTime {
|
|
|
1485
1604
|
this.__inFlight.delete(task.uuid);
|
|
1486
1605
|
}
|
|
1487
1606
|
});
|
|
1607
|
+
if (!started) {
|
|
1608
|
+
this.__inFlight.delete(task.uuid);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* @internal
|
|
1614
|
+
* @memberOf MailTime
|
|
1615
|
+
* @name ___isStopped
|
|
1616
|
+
* @description `true` after `destroy()`, or while paused during a scheduler-driven scan. Queue adapters check it to end an `iterate` scan early.
|
|
1617
|
+
* @returns {boolean}
|
|
1618
|
+
*/
|
|
1619
|
+
get ___isStopped() {
|
|
1620
|
+
return this.__isDestroyed || (this.__isPaused && this.__schedulerScans > 0);
|
|
1488
1621
|
}
|
|
1489
1622
|
|
|
1490
1623
|
/**
|
|
@@ -1531,6 +1664,7 @@ class MailTime {
|
|
|
1531
1664
|
task.tries = tries;
|
|
1532
1665
|
task.isSending = true;
|
|
1533
1666
|
task.sendingAt = sendingAt;
|
|
1667
|
+
this.__uncertainRenewals.delete(task);
|
|
1534
1668
|
|
|
1535
1669
|
let transportIndex = task.transport;
|
|
1536
1670
|
if (!this.___isHealthyTransport(transportIndex)) {
|
|
@@ -1584,20 +1718,14 @@ class MailTime {
|
|
|
1584
1718
|
if (isFullyDelivered) {
|
|
1585
1719
|
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
1586
1720
|
|
|
1587
|
-
const
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
let completed = false;
|
|
1596
|
-
if (!this.keepHistory) {
|
|
1597
|
-
completed = await this.queue.remove(task, leaseRemove);
|
|
1598
|
-
} else {
|
|
1599
|
-
completed = await this.queue.update(task, leaseUpdate);
|
|
1600
|
-
}
|
|
1721
|
+
const completed = await this.___outcomeWrite(task, (t) => (!this.keepHistory
|
|
1722
|
+
? this.queue.remove(t, __leaseRemoveOpts(t))
|
|
1723
|
+
: this.queue.update(t, __withLease(t, {
|
|
1724
|
+
isSent: true,
|
|
1725
|
+
isSending: false,
|
|
1726
|
+
sendingAt: 0,
|
|
1727
|
+
mailOptions: task.mailOptions,
|
|
1728
|
+
}))));
|
|
1601
1729
|
|
|
1602
1730
|
if (!completed) {
|
|
1603
1731
|
this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
|
|
@@ -1615,21 +1743,15 @@ class MailTime {
|
|
|
1615
1743
|
if (task.tries >= this.maxTries) {
|
|
1616
1744
|
this.___finalizeRejected(task, info);
|
|
1617
1745
|
|
|
1618
|
-
const
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
let finalized = false;
|
|
1628
|
-
if (!this.keepHistory) {
|
|
1629
|
-
finalized = await this.queue.remove(task, leaseRemove);
|
|
1630
|
-
} else {
|
|
1631
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
1632
|
-
}
|
|
1746
|
+
const finalized = await this.___outcomeWrite(task, (t) => (!this.keepHistory
|
|
1747
|
+
? this.queue.remove(t, __leaseRemoveOpts(t))
|
|
1748
|
+
: this.queue.update(t, __withLease(t, {
|
|
1749
|
+
isSent: false,
|
|
1750
|
+
isFailed: true,
|
|
1751
|
+
isSending: false,
|
|
1752
|
+
sendingAt: 0,
|
|
1753
|
+
mailOptions: task.mailOptions,
|
|
1754
|
+
}))));
|
|
1633
1755
|
|
|
1634
1756
|
if (!finalized) {
|
|
1635
1757
|
this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
|
|
@@ -1654,19 +1776,19 @@ class MailTime {
|
|
|
1654
1776
|
}
|
|
1655
1777
|
|
|
1656
1778
|
const nextSendAt = Date.now() + this.retryDelay;
|
|
1657
|
-
const released = await this.queue.update(
|
|
1779
|
+
const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
|
|
1658
1780
|
isSending: false,
|
|
1659
1781
|
sendingAt: 0,
|
|
1660
1782
|
sendAt: nextSendAt,
|
|
1661
1783
|
mailOptions: task.mailOptions,
|
|
1662
|
-
}));
|
|
1784
|
+
})));
|
|
1663
1785
|
if (!released) {
|
|
1664
1786
|
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
1665
1787
|
return;
|
|
1666
1788
|
}
|
|
1667
1789
|
this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
|
|
1668
1790
|
} catch (completionError) {
|
|
1669
|
-
|
|
1791
|
+
this.___reportWriteFailure(task, completionError);
|
|
1670
1792
|
} finally {
|
|
1671
1793
|
resolve();
|
|
1672
1794
|
}
|
|
@@ -1682,7 +1804,11 @@ class MailTime {
|
|
|
1682
1804
|
return;
|
|
1683
1805
|
}
|
|
1684
1806
|
logError('Exception during runtime:', e);
|
|
1685
|
-
|
|
1807
|
+
try {
|
|
1808
|
+
await this.___handleError(task, e, {});
|
|
1809
|
+
} catch (completionError) {
|
|
1810
|
+
this.___reportWriteFailure(task, completionError);
|
|
1811
|
+
}
|
|
1686
1812
|
}
|
|
1687
1813
|
}
|
|
1688
1814
|
|
|
@@ -1699,10 +1825,15 @@ class MailTime {
|
|
|
1699
1825
|
return;
|
|
1700
1826
|
}
|
|
1701
1827
|
const limit = this.mode === 'one' ? 1 : Infinity;
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1828
|
+
this.__schedulerScans++;
|
|
1829
|
+
try {
|
|
1830
|
+
return await this.queue.iterate({
|
|
1831
|
+
limit,
|
|
1832
|
+
sendingTimeout: this.sendingTimeout,
|
|
1833
|
+
});
|
|
1834
|
+
} finally {
|
|
1835
|
+
this.__schedulerScans--;
|
|
1836
|
+
}
|
|
1706
1837
|
}
|
|
1707
1838
|
|
|
1708
1839
|
/**
|
|
@@ -1739,51 +1870,163 @@ class MailTime {
|
|
|
1739
1870
|
* @internal
|
|
1740
1871
|
* @memberOf MailTime
|
|
1741
1872
|
* @name ___verifyTransports
|
|
1742
|
-
* @description Probe each transport's `verify()` once at startup.
|
|
1873
|
+
* @description Probe each transport's `verify()` once at startup, in parallel. A transport whose probe fails is quarantined (skipped during rotation) and reported through `onError(error, null, { transportIndex, phase: 'verify' })`. A probe that neither settles nor fails within `verifyTimeout` stops delaying `ready()` and leaves the transport usable; a verdict that arrives later still applies. Throws if every transport is quarantined.
|
|
1743
1874
|
* @returns {Promise<void>}
|
|
1744
1875
|
*/
|
|
1745
1876
|
async ___verifyTransports() {
|
|
1746
1877
|
this.__debug('[private verifyTransports]');
|
|
1747
|
-
|
|
1878
|
+
await Promise.all(this.transports.map(async (transport, index) => {
|
|
1748
1879
|
if (!transport || typeof transport.verify !== 'function') {
|
|
1749
|
-
return
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
const outcome = await this.___verifyOne(transport, index);
|
|
1883
|
+
if (outcome === 'timeout' && !this.__isDestroyed) {
|
|
1884
|
+
console.warn(`[WARN] [mail-time] [verifyTransports] transport #${index} verify() gave no verdict within ${this.verifyTimeout}ms; keeping it usable, a late verdict still applies`);
|
|
1885
|
+
}
|
|
1886
|
+
}));
|
|
1887
|
+
|
|
1888
|
+
if (this.__unhealthyTransports.size === this.transports.length) {
|
|
1889
|
+
throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/**
|
|
1894
|
+
* @internal
|
|
1895
|
+
* @memberOf MailTime
|
|
1896
|
+
* @name ___verifyOne
|
|
1897
|
+
* @description Start one `transport.verify(callback)` probe bounded by `verifyTimeout`. A callback is always passed. The first of the callback, a returned thenable, a synchronous throw, or a synchronous `true`/`false` decides the verdict. `false` is what Nodemailer's `Mailer#verify` returns, without calling back, when the underlying transport has no `verify` method. Any other synchronous non-thenable return value (for example the timer handle an expression-bodied arrow returns) is ignored, so a callback that reports an error later still counts, and a verify that never calls back waits for the timeout. The verdict is applied through `___applyVerdict` even when it arrives after the timeout, guarded by a per-transport generation. Resolves `'ok'`, `'fail'`, or `'timeout'` (the wait ended without a verdict or the instance was destroyed).
|
|
1898
|
+
* @param {MailTimeTransport} transport
|
|
1899
|
+
* @param {number} index
|
|
1900
|
+
* @returns {Promise<'ok' | 'fail' | 'timeout'>}
|
|
1901
|
+
*/
|
|
1902
|
+
___verifyOne(transport, index) {
|
|
1903
|
+
const gen = (this.__verifyGen[index] || 0) + 1;
|
|
1904
|
+
this.__verifyGen[index] = gen;
|
|
1905
|
+
return new Promise((resolve) => {
|
|
1906
|
+
let decided = false;
|
|
1907
|
+
let waiting = true;
|
|
1908
|
+
const stopWaiting = (outcome) => {
|
|
1909
|
+
if (!waiting) {
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
waiting = false;
|
|
1913
|
+
clearTimeout(timer);
|
|
1914
|
+
this.__probeCancels.delete(cancel);
|
|
1915
|
+
resolve(outcome);
|
|
1916
|
+
};
|
|
1917
|
+
const cancel = () => stopWaiting('timeout');
|
|
1918
|
+
const timer = setTimeout(cancel, this.verifyTimeout);
|
|
1919
|
+
if (typeof timer.unref === 'function') {
|
|
1920
|
+
timer.unref();
|
|
1750
1921
|
}
|
|
1922
|
+
this.__probeCancels.add(cancel);
|
|
1923
|
+
|
|
1924
|
+
const decide = (error) => {
|
|
1925
|
+
if (decided) {
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
decided = true;
|
|
1929
|
+
this.___applyVerdict(index, gen, error);
|
|
1930
|
+
stopWaiting(error ? 'fail' : 'ok');
|
|
1931
|
+
};
|
|
1932
|
+
|
|
1751
1933
|
try {
|
|
1752
|
-
|
|
1753
|
-
|
|
1934
|
+
const returned = transport.verify((error) => decide(error || void 0));
|
|
1935
|
+
if (returned && typeof returned.then === 'function') {
|
|
1936
|
+
returned.then(() => decide(), (error) => decide(error || new Error('[mail-time] [verifyTransports] verify() rejected')));
|
|
1937
|
+
} else if (returned === true || returned === false) {
|
|
1938
|
+
decide();
|
|
1939
|
+
}
|
|
1754
1940
|
} catch (error) {
|
|
1755
|
-
|
|
1941
|
+
decide(error || new Error('[mail-time] [verifyTransports] verify() threw'));
|
|
1756
1942
|
}
|
|
1757
|
-
})
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1758
1945
|
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1946
|
+
/**
|
|
1947
|
+
* @internal
|
|
1948
|
+
* @memberOf MailTime
|
|
1949
|
+
* @name ___applyVerdict
|
|
1950
|
+
* @description Apply a probe verdict to the health state. Ignored after `destroy()` and when a newer probe of the same transport has started (generation guard). Success removes the transport from quarantine. Failure quarantines it and fires `onError` once per quarantine episode; a failure for an already quarantined transport releases its re-probe slot and doubles the backoff.
|
|
1951
|
+
* @param {number} index
|
|
1952
|
+
* @param {number} gen
|
|
1953
|
+
* @param {unknown} [error]
|
|
1954
|
+
* @returns {void}
|
|
1955
|
+
*/
|
|
1956
|
+
___applyVerdict(index, gen, error) {
|
|
1957
|
+
if (this.__isDestroyed || this.__verifyGen[index] !== gen) {
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
const quarantined = this.__unhealthyTransports.has(index);
|
|
1961
|
+
if (!error) {
|
|
1962
|
+
if (!quarantined) {
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
this.__unhealthyTransports.delete(index);
|
|
1966
|
+
this.__reprobe.delete(index);
|
|
1967
|
+
if (this.strategy === 'backup' && index < this.transport) {
|
|
1968
|
+
this.transport = index;
|
|
1762
1969
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
|
|
1970
|
+
console.log(`[mail-time] [verifyTransports] transport #${index} recovered; back in rotation`);
|
|
1971
|
+
return;
|
|
1766
1972
|
}
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1973
|
+
if (quarantined) {
|
|
1974
|
+
const st = this.__reprobe.get(index);
|
|
1975
|
+
if (st) {
|
|
1976
|
+
st.inFlight = false;
|
|
1977
|
+
st.count++;
|
|
1978
|
+
st.nextProbeAt = Date.now() + Math.min(REPROBE_BASE_MS * Math.pow(2, st.count), REPROBE_MAX_MS);
|
|
1979
|
+
this.__debug(`[private reprobe] transport #${index} failed, next probe at ${new Date(st.nextProbeAt)}`);
|
|
1980
|
+
}
|
|
1981
|
+
return;
|
|
1770
1982
|
}
|
|
1983
|
+
this.__unhealthyTransports.add(index);
|
|
1984
|
+
this.__reprobe.set(index, { count: 0, nextProbeAt: Date.now() + REPROBE_BASE_MS, inFlight: false });
|
|
1985
|
+
if (this.transport === index && this.__unhealthyTransports.size < this.transports.length) {
|
|
1986
|
+
this.transport = this.___nextHealthyTransport(index);
|
|
1987
|
+
}
|
|
1988
|
+
logError(`[mail-time] [verifyTransports] transport #${index} failed verification`, error);
|
|
1989
|
+
callHook('onError', this.onError, error, null, { transportIndex: index, phase: 'verify' });
|
|
1990
|
+
}
|
|
1771
1991
|
|
|
1772
|
-
|
|
1773
|
-
|
|
1992
|
+
/**
|
|
1993
|
+
* @internal
|
|
1994
|
+
* @memberOf MailTime
|
|
1995
|
+
* @name ___maybeReprobe
|
|
1996
|
+
* @description Start one background `verify()` probe for a quarantined transport when its backoff has elapsed and no probe is in flight. Never awaited by the send path. Backoff starts at 60 s, doubles per failed probe and caps at 15 min. A probe that times out stays in flight until its verdict arrives, so at most one `verify()` is outstanding per transport. Failures only reach `__debug`. No probes for `type: 'client'`, `verifyTransports: false`, or after `destroy()`.
|
|
1997
|
+
* @param {number} index
|
|
1998
|
+
* @returns {void}
|
|
1999
|
+
*/
|
|
2000
|
+
___maybeReprobe(index) {
|
|
2001
|
+
if (this.__isDestroyed || this.type !== 'server' || !this.verifyTransports) {
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
const st = this.__reprobe.get(index);
|
|
2005
|
+
const transport = this.transports[index];
|
|
2006
|
+
if (!st || st.inFlight || st.nextProbeAt > Date.now() || !transport || typeof transport.verify !== 'function') {
|
|
2007
|
+
return;
|
|
1774
2008
|
}
|
|
2009
|
+
st.inFlight = true;
|
|
2010
|
+
this.__debug(`[private reprobe] transport #${index}, attempt after ${st.count} failure(s)`);
|
|
2011
|
+
// The verdict, not the timeout, releases `inFlight` (see ___applyVerdict), so a hung
|
|
2012
|
+
// verify() never overlaps with a new probe on the same transport.
|
|
2013
|
+
this.___verifyOne(transport, index);
|
|
1775
2014
|
}
|
|
1776
2015
|
|
|
1777
2016
|
/**
|
|
1778
2017
|
* @internal
|
|
1779
2018
|
* @memberOf MailTime
|
|
1780
2019
|
* @name ___isHealthyTransport
|
|
1781
|
-
* @description Return true when the transport at `index`
|
|
2020
|
+
* @description Return true when the transport at `index` is not quarantined. A quarantined transport whose backoff has elapsed triggers a background re-probe; this call still returns false.
|
|
1782
2021
|
* @param {number} index
|
|
1783
2022
|
* @returns {boolean}
|
|
1784
2023
|
*/
|
|
1785
2024
|
___isHealthyTransport(index) {
|
|
1786
|
-
|
|
2025
|
+
if (!this.__unhealthyTransports.has(index)) {
|
|
2026
|
+
return true;
|
|
2027
|
+
}
|
|
2028
|
+
this.___maybeReprobe(index);
|
|
2029
|
+
return false;
|
|
1787
2030
|
}
|
|
1788
2031
|
|
|
1789
2032
|
/**
|