mail-time 5.2.1 → 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 +19 -8
- package/index.cjs +467 -104
- package/index.d.cts +19 -75
- package/index.d.ts +19 -75
- package/index.js +278 -78
- package/package.json +4 -4
- package/recipient-policy-lease.js +40 -8
- package/recipient-policy.js +151 -20
package/index.cjs
CHANGED
|
@@ -2446,29 +2446,113 @@ const mailTimePreset = (name, overrides) => {
|
|
|
2446
2446
|
|
|
2447
2447
|
const policyError = (message) => new Error(`[mail-time] [recipientPolicies] ${message}`);
|
|
2448
2448
|
const SIMPLE_MAILBOX = /^[^\s<>,;:"()\\\[\]@]+@[^\s<>,;:"()\\\[\]@]+$/u;
|
|
2449
|
+
// Unquoted display-name characters that are auto-quoted in the sent header; `,` `;` `:` `<` `>` stay rejected.
|
|
2450
|
+
const QUOTABLE_SPECIALS = /[()[\]@\\]/u;
|
|
2451
|
+
const UNSAFE_CHARS = /[\r\n\u0000]/u;
|
|
2449
2452
|
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2453
|
+
/**
|
|
2454
|
+
* Error for an address that cannot be parsed into exactly one mailbox. The message names
|
|
2455
|
+
* the field and the rule that failed, never the address or display name itself.
|
|
2456
|
+
*/
|
|
2457
|
+
const addressError = (field, reason) => {
|
|
2458
|
+
const error = policyError(`\`${field}\` ${reason}`);
|
|
2459
|
+
error.code = 'MAIL_TIME_INVALID_ADDRESS';
|
|
2460
|
+
error.field = field;
|
|
2461
|
+
return error;
|
|
2462
|
+
};
|
|
2463
|
+
|
|
2464
|
+
const isAddressError = (error) => error?.code === 'MAIL_TIME_INVALID_ADDRESS';
|
|
2465
|
+
|
|
2466
|
+
const toMailbox = (address, field) => {
|
|
2467
|
+
const trimmed = address.trim();
|
|
2468
|
+
if (!SIMPLE_MAILBOX.test(trimmed)) throw addressError(field, 'must contain one address such as user@example.com; quoted local parts are not supported');
|
|
2469
|
+
return trimmed.toLowerCase();
|
|
2470
|
+
};
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* Parse one Nodemailer mailbox string: `addr`, `<addr>`, or `display name <addr>`, where the
|
|
2474
|
+
* display name is any mix of atoms and quoted strings with backslash escapes. Unquoted
|
|
2475
|
+
* `( ) [ ] @ \\` in a display name set `quote`, so the sent header carries the name quoted.
|
|
2476
|
+
* Groups, comma-separated lists, and text after `>` are rejected rather than guessed at.
|
|
2477
|
+
* @returns {{ name: string, address: string, quote: boolean }} `address` keeps its case.
|
|
2478
|
+
*/
|
|
2479
|
+
const parseMailboxString = (input, field) => {
|
|
2480
|
+
let display = '';
|
|
2481
|
+
let name = '';
|
|
2482
|
+
let angle = null;
|
|
2483
|
+
let quoted = false;
|
|
2484
|
+
for (let i = 0; i < input.length; i++) {
|
|
2485
|
+
const char = input[i];
|
|
2486
|
+
if (quoted) {
|
|
2487
|
+
if (char === '\\') {
|
|
2488
|
+
if (++i >= input.length) break;
|
|
2489
|
+
name += input[i];
|
|
2490
|
+
} else if (char === '"') {
|
|
2491
|
+
quoted = false;
|
|
2492
|
+
} else {
|
|
2493
|
+
name += char;
|
|
2494
|
+
}
|
|
2495
|
+
continue;
|
|
2496
|
+
}
|
|
2497
|
+
if (char === '"') {
|
|
2498
|
+
quoted = true;
|
|
2499
|
+
} else if (char === '<') {
|
|
2500
|
+
const close = input.indexOf('>', i + 1);
|
|
2501
|
+
if (close === -1) throw addressError(field, 'has an unclosed angle bracket');
|
|
2502
|
+
angle = input.slice(i + 1, close);
|
|
2503
|
+
if (input.slice(close + 1).trim()) throw addressError(field, 'must end after the angle-bracket address; use an array for multiple recipients');
|
|
2504
|
+
break;
|
|
2505
|
+
} else if (char === ',') {
|
|
2506
|
+
throw addressError(field, 'must contain one mailbox; use an array for multiple recipients or quote a display name that contains a comma');
|
|
2507
|
+
} else if (char === ';' || char === ':') {
|
|
2508
|
+
throw addressError(field, 'must not use group syntax');
|
|
2509
|
+
} else if (char === '>') {
|
|
2510
|
+
throw addressError(field, 'has an unquoted `>` in its display name; wrap the name in double quotes');
|
|
2511
|
+
} else {
|
|
2512
|
+
display += char;
|
|
2513
|
+
name += char;
|
|
2514
|
+
}
|
|
2454
2515
|
}
|
|
2455
|
-
|
|
2456
|
-
if (
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
address
|
|
2516
|
+
if (quoted) throw addressError(field, 'has an unterminated quoted display name');
|
|
2517
|
+
if (angle === null) {
|
|
2518
|
+
if (display !== input) throw addressError(field, 'must contain one address such as user@example.com; quoted local parts are not supported');
|
|
2519
|
+
toMailbox(input, field);
|
|
2520
|
+
return { name: '', address: input, quote: false };
|
|
2460
2521
|
}
|
|
2461
|
-
|
|
2462
|
-
return address.
|
|
2522
|
+
toMailbox(angle, field);
|
|
2523
|
+
return { name: name.replace(/\s+/gu, ' ').trim(), address: angle.trim(), quote: QUOTABLE_SPECIALS.test(display) };
|
|
2524
|
+
};
|
|
2525
|
+
|
|
2526
|
+
/**
|
|
2527
|
+
* Normalize exactly one mailbox (string or `{ name?, address }`) to its lowercase address.
|
|
2528
|
+
* @param {unknown} value
|
|
2529
|
+
* @param {string} [field] - Field label used in errors, e.g. `to[1]` or `envelope.from`.
|
|
2530
|
+
* @returns {string}
|
|
2531
|
+
*/
|
|
2532
|
+
const normalizePolicyAddress = (value, field = 'address') => {
|
|
2533
|
+
if (typeof value === 'string') {
|
|
2534
|
+
if (UNSAFE_CHARS.test(value)) throw addressError(field, 'must not contain line breaks or NUL characters');
|
|
2535
|
+
if (!value.trim()) throw addressError(field, 'must not be empty');
|
|
2536
|
+
return parseMailboxString(value.trim(), field).address.toLowerCase();
|
|
2537
|
+
}
|
|
2538
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw addressError(field, 'must be an address string or a { name, address } object');
|
|
2539
|
+
if (typeof value.address !== 'string') throw addressError(field, 'object requires a string `address`');
|
|
2540
|
+
if (value.name !== void 0 && typeof value.name !== 'string') throw addressError(field, 'object `name` must be a string');
|
|
2541
|
+
if (UNSAFE_CHARS.test(value.address) || (value.name && UNSAFE_CHARS.test(value.name))) {
|
|
2542
|
+
throw addressError(field, 'must not contain line breaks or NUL characters');
|
|
2543
|
+
}
|
|
2544
|
+
return toMailbox(value.address, field);
|
|
2463
2545
|
};
|
|
2464
2546
|
|
|
2465
2547
|
const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
2466
2548
|
const recipients = new Map();
|
|
2467
2549
|
const explicit = compiled.envelope && hasOwnProp(compiled.envelope, 'to');
|
|
2468
|
-
const add = (value, source, authoritative) => {
|
|
2469
|
-
|
|
2550
|
+
const add = (value, source, authoritative, label = source) => {
|
|
2551
|
+
const list = Array.isArray(value);
|
|
2552
|
+
const entries = list ? value : [value];
|
|
2553
|
+
for (let i = 0; i < entries.length; i++) {
|
|
2470
2554
|
let address;
|
|
2471
|
-
try { address = normalizePolicyAddress(
|
|
2555
|
+
try { address = normalizePolicyAddress(entries[i], list ? `${label}[${i}]` : label); }
|
|
2472
2556
|
catch (error) { if (authoritative) throw error; else continue; }
|
|
2473
2557
|
if (!recipients.has(address)) {
|
|
2474
2558
|
if (!authoritative) continue;
|
|
@@ -2478,7 +2562,7 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
|
2478
2562
|
if (!sources.includes(source)) sources.push(source);
|
|
2479
2563
|
}
|
|
2480
2564
|
};
|
|
2481
|
-
if (explicit) add(compiled.envelope.to, 'envelope', true);
|
|
2565
|
+
if (explicit) add(compiled.envelope.to, 'envelope', true, 'envelope.to');
|
|
2482
2566
|
for (const source of ['to', 'cc', 'bcc']) {
|
|
2483
2567
|
if (hasOwnProp(compiled, source) && compiled[source] !== void 0) add(compiled[source], source, !explicit);
|
|
2484
2568
|
}
|
|
@@ -2488,14 +2572,61 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
|
2488
2572
|
}
|
|
2489
2573
|
const envelope = { to: [...recipients.keys()] };
|
|
2490
2574
|
if (compiled.envelope && hasOwnProp(compiled.envelope, 'from')) {
|
|
2491
|
-
envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from);
|
|
2575
|
+
envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from, 'envelope.from');
|
|
2492
2576
|
} else {
|
|
2493
|
-
|
|
2494
|
-
|
|
2577
|
+
// Same precedence as Nodemailer's getEnvelope(): From, then Sender, then Reply-To.
|
|
2578
|
+
const field = ['from', 'sender', 'replyTo'].find((key) => compiled[key]);
|
|
2579
|
+
if (field) envelope.from = normalizePolicyAddress(compiled[field], field);
|
|
2495
2580
|
}
|
|
2496
2581
|
return { envelope, recipients: [...recipients.values()] };
|
|
2497
2582
|
};
|
|
2498
2583
|
|
|
2584
|
+
/**
|
|
2585
|
+
* Return `compiled` with every header mailbox string whose display name needs quoting replaced by
|
|
2586
|
+
* `{ name, address }`, so Nodemailer renders the same mailbox the policy checked. Other entries,
|
|
2587
|
+
* including ones that do not parse, are left as they are. The input is never mutated.
|
|
2588
|
+
*/
|
|
2589
|
+
const rewritePolicyHeaders = (compiled) => {
|
|
2590
|
+
let result = compiled;
|
|
2591
|
+
const rewrite = (value) => {
|
|
2592
|
+
if (typeof value !== 'string' || UNSAFE_CHARS.test(value)) return value;
|
|
2593
|
+
try {
|
|
2594
|
+
const { name, address, quote } = parseMailboxString(value.trim(), 'header');
|
|
2595
|
+
return quote ? { name, address } : value;
|
|
2596
|
+
} catch { return value; }
|
|
2597
|
+
};
|
|
2598
|
+
for (const key of ['from', 'sender', 'replyTo', 'to', 'cc', 'bcc']) {
|
|
2599
|
+
if (!hasOwnProp(compiled, key)) continue;
|
|
2600
|
+
const value = compiled[key];
|
|
2601
|
+
const next = Array.isArray(value) ? value.map(rewrite) : rewrite(value);
|
|
2602
|
+
if (Array.isArray(value) ? next.some((entry, i) => entry !== value[i]) : next !== value) {
|
|
2603
|
+
if (result === compiled) result = { ...compiled };
|
|
2604
|
+
result[key] = next;
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
return result;
|
|
2608
|
+
};
|
|
2609
|
+
|
|
2610
|
+
/**
|
|
2611
|
+
* Validate every address field present in a letter with the policy parser, so an unparseable
|
|
2612
|
+
* address is rejected at `sendMail()` instead of after a queue round trip. Absent fields are
|
|
2613
|
+
* skipped. Throws the same `MAIL_TIME_INVALID_ADDRESS` error the send path would.
|
|
2614
|
+
* @param {object} mailOptions
|
|
2615
|
+
* @returns {void}
|
|
2616
|
+
*/
|
|
2617
|
+
const validatePolicyMailOptions = (mailOptions) => {
|
|
2618
|
+
const check = (value, label) => {
|
|
2619
|
+
if (value === void 0) return;
|
|
2620
|
+
if (Array.isArray(value)) value.forEach((entry, i) => normalizePolicyAddress(entry, `${label}[${i}]`));
|
|
2621
|
+
else normalizePolicyAddress(value, label);
|
|
2622
|
+
};
|
|
2623
|
+
for (const key of ['from', 'sender', 'replyTo', 'to', 'cc', 'bcc']) check(mailOptions[key], key);
|
|
2624
|
+
if (isPlainObject(mailOptions.envelope)) {
|
|
2625
|
+
check(mailOptions.envelope.to, 'envelope.to');
|
|
2626
|
+
if (mailOptions.envelope.from !== '') check(mailOptions.envelope.from, 'envelope.from');
|
|
2627
|
+
}
|
|
2628
|
+
};
|
|
2629
|
+
|
|
2499
2630
|
const validateRecipientPolicies = (value, queue) => {
|
|
2500
2631
|
if (value === void 0) return null;
|
|
2501
2632
|
if (!Array.isArray(value) || !value.length) throw policyError('recipientPolicies must be a nonempty array');
|
|
@@ -2532,7 +2663,7 @@ const validatePolicyResult = (raw, hook, context) => {
|
|
|
2532
2663
|
const decisions = new Map();
|
|
2533
2664
|
for (const item of raw.decisions) {
|
|
2534
2665
|
if (!isPlainObject(item)) throw policyError('decision must be an object');
|
|
2535
|
-
const address = normalizePolicyAddress(item.address);
|
|
2666
|
+
const address = normalizePolicyAddress(item.address, 'decision.address');
|
|
2536
2667
|
if (!batch.has(address)) throw policyError('decision address is outside the input batch');
|
|
2537
2668
|
const allowed = hook === 'beforeSend' ? item.status === 'suppressed' : (item.status === 'retry' || item.status === 'rejected');
|
|
2538
2669
|
if (!allowed) throw policyError('invalid decision status for this phase');
|
|
@@ -2666,7 +2797,11 @@ const normalizeRejections = (error, info, transport) => {
|
|
|
2666
2797
|
};
|
|
2667
2798
|
|
|
2668
2799
|
class RecipientPolicyLease {
|
|
2669
|
-
constructor({ task, queue, interval, maxRenewals, shouldAbort, report }) {
|
|
2800
|
+
constructor({ task, queue, interval, maxRenewals, sendingTimeout, shouldAbort, report }) {
|
|
2801
|
+
if (typeof sendingTimeout !== 'number' || !(sendingTimeout > 0)) throw new TypeError('[mail-time] [recipientPolicies] RecipientPolicyLease requires a positive sendingTimeout');
|
|
2802
|
+
this.__sendingTimeout = sendingTimeout;
|
|
2803
|
+
this.__renewStale = false;
|
|
2804
|
+
this.__liveAtAttempt = false;
|
|
2670
2805
|
this.__task = task;
|
|
2671
2806
|
this.__queue = queue;
|
|
2672
2807
|
this.__shouldAbort = shouldAbort;
|
|
@@ -2674,6 +2809,8 @@ class RecipientPolicyLease {
|
|
|
2674
2809
|
this.__closed = false;
|
|
2675
2810
|
this.__finishing = false;
|
|
2676
2811
|
this.__tail = Promise.resolve();
|
|
2812
|
+
this.__renewUncertain = false;
|
|
2813
|
+
this.__attemptedAt = 0;
|
|
2677
2814
|
this.__timer = null;
|
|
2678
2815
|
let pending = false;
|
|
2679
2816
|
let renewals = 0;
|
|
@@ -2688,6 +2825,8 @@ class RecipientPolicyLease {
|
|
|
2688
2825
|
renewals++;
|
|
2689
2826
|
this.__enqueue(async (guard) => {
|
|
2690
2827
|
const fields = { isSending: true, sendingAt: Math.max(Date.now(), this.__task.sendingAt + 1) };
|
|
2828
|
+
this.__attemptedAt = fields.sendingAt;
|
|
2829
|
+
this.__liveAtAttempt = this.__task.sendingAt > fields.sendingAt - this.__sendingTimeout;
|
|
2691
2830
|
return await this.__write(fields, guard);
|
|
2692
2831
|
}).finally(() => { pending = false; });
|
|
2693
2832
|
}, interval);
|
|
@@ -2707,17 +2846,41 @@ class RecipientPolicyLease {
|
|
|
2707
2846
|
this.__closed = true;
|
|
2708
2847
|
}
|
|
2709
2848
|
|
|
2710
|
-
__enqueue(operation) {
|
|
2849
|
+
__enqueue(operation, phase = 'renew') {
|
|
2711
2850
|
const pending = this.__tail.then(async () => {
|
|
2712
2851
|
if (!this.active) return false;
|
|
2713
2852
|
const guard = { leaseTries: this.__task.tries, leaseSendingAt: this.__task.sendingAt };
|
|
2714
2853
|
try {
|
|
2715
|
-
|
|
2716
|
-
if (!ok)
|
|
2854
|
+
let ok = await operation(guard);
|
|
2855
|
+
if (!ok) {
|
|
2856
|
+
// A renewal write that threw may have been applied by storage, which leaves the
|
|
2857
|
+
// guard stale. Retry once with the attempted stamp: a peer takeover bumps tries or
|
|
2858
|
+
// stamps a later sendingAt while the lease is live, so the retry cannot match a
|
|
2859
|
+
// taken-over row. Report if it still fails.
|
|
2860
|
+
if (this.__renewStale && phase !== 'renew') {
|
|
2861
|
+
// The claim was already stale when the renewal threw: no retry, report the loss.
|
|
2862
|
+
this.__renewStale = false;
|
|
2863
|
+
this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
|
|
2864
|
+
} else if (this.__renewUncertain && phase !== 'renew') {
|
|
2865
|
+
this.__renewUncertain = false;
|
|
2866
|
+
if (this.__shouldAbort()) {
|
|
2867
|
+
this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
|
|
2868
|
+
this.__halt();
|
|
2869
|
+
return false;
|
|
2870
|
+
}
|
|
2871
|
+
ok = await operation({ ...guard, leaseSendingAt: this.__attemptedAt });
|
|
2872
|
+
if (ok && phase === 'checkpoint') this.__task.sendingAt = this.__attemptedAt;
|
|
2873
|
+
if (!ok) this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
|
|
2874
|
+
}
|
|
2875
|
+
if (!ok) this.__halt();
|
|
2876
|
+
}
|
|
2717
2877
|
return ok;
|
|
2718
2878
|
} catch (error) {
|
|
2719
|
-
|
|
2720
|
-
|
|
2879
|
+
// A thrown renewal write only stops further renewals. The lease stays open so
|
|
2880
|
+
// finish() still records the outcome; storage-side CAS protects row ownership.
|
|
2881
|
+
if (phase === 'renew') { this.__clearTimer(); this.__renewUncertain = this.__liveAtAttempt; this.__renewStale = !this.__liveAtAttempt; }
|
|
2882
|
+
else this.__halt();
|
|
2883
|
+
this.__report(error, phase);
|
|
2721
2884
|
return false;
|
|
2722
2885
|
}
|
|
2723
2886
|
});
|
|
@@ -2733,7 +2896,7 @@ class RecipientPolicyLease {
|
|
|
2733
2896
|
|
|
2734
2897
|
update(fields) {
|
|
2735
2898
|
if (this.__finishing) return Promise.resolve(false);
|
|
2736
|
-
return this.__enqueue((guard) => this.__write(fields, guard));
|
|
2899
|
+
return this.__enqueue((guard) => this.__write(fields, guard), 'checkpoint');
|
|
2737
2900
|
}
|
|
2738
2901
|
|
|
2739
2902
|
finish(fields, remove) {
|
|
@@ -2745,7 +2908,7 @@ class RecipientPolicyLease {
|
|
|
2745
2908
|
if (ok) Object.assign(this.__task, fields);
|
|
2746
2909
|
this.__halt();
|
|
2747
2910
|
return ok;
|
|
2748
|
-
});
|
|
2911
|
+
}, 'complete');
|
|
2749
2912
|
}
|
|
2750
2913
|
|
|
2751
2914
|
async stop() {
|
|
@@ -3023,9 +3186,21 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
3023
3186
|
*/
|
|
3024
3187
|
|
|
3025
3188
|
/**
|
|
3026
|
-
* @typedef {{
|
|
3189
|
+
* @typedef {{ failedWrites: number }} MailTimeDrainResult
|
|
3190
|
+
*/
|
|
3191
|
+
|
|
3192
|
+
/**
|
|
3193
|
+
* `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.
|
|
3194
|
+
* @typedef {{ phase?: 'verify' | 'complete' | 'checkpoint', transportIndex?: number, attempt?: number, [key: string]: unknown }} MailTimeErrorDetails
|
|
3195
|
+
*/
|
|
3196
|
+
|
|
3197
|
+
/**
|
|
3198
|
+
* @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
|
|
3027
3199
|
*/
|
|
3028
3200
|
|
|
3201
|
+
const REPROBE_BASE_MS = 60000;
|
|
3202
|
+
const REPROBE_MAX_MS = 900000;
|
|
3203
|
+
|
|
3029
3204
|
/**
|
|
3030
3205
|
* Class of MailTime.
|
|
3031
3206
|
* With recipientPolicies, terminal callbacks receive grouped recipients and a complete summary after persistence.
|
|
@@ -3128,6 +3303,7 @@ class MailTime {
|
|
|
3128
3303
|
this.__readyPromise = null;
|
|
3129
3304
|
this.__schedulerTimer = null;
|
|
3130
3305
|
this.__inFlight = new Set();
|
|
3306
|
+
this.__failedWrites = 0;
|
|
3131
3307
|
this.__pool = createPool(this.concurrency);
|
|
3132
3308
|
|
|
3133
3309
|
this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
|
|
@@ -3135,7 +3311,12 @@ class MailTime {
|
|
|
3135
3311
|
this.transports = Array.isArray(opts.transports) ? opts.transports : [];
|
|
3136
3312
|
this.transport = 0;
|
|
3137
3313
|
this.verifyTransports = opts.verifyTransports !== false;
|
|
3314
|
+
this.verifyTimeout = (typeof opts.verifyTimeout === 'number' && opts.verifyTimeout > 0) ? Math.min(opts.verifyTimeout, 2147483647) : 30000;
|
|
3138
3315
|
this.__unhealthyTransports = new Set();
|
|
3316
|
+
this.__verifyGen = [];
|
|
3317
|
+
this.__reprobe = new Map();
|
|
3318
|
+
this.__probeCancels = new Set();
|
|
3319
|
+
this.__uncertainRenewals = new WeakMap();
|
|
3139
3320
|
|
|
3140
3321
|
if (typeof opts.from === 'string') {
|
|
3141
3322
|
const fromStr = opts.from;
|
|
@@ -3146,6 +3327,16 @@ class MailTime {
|
|
|
3146
3327
|
this.from = false;
|
|
3147
3328
|
}
|
|
3148
3329
|
|
|
3330
|
+
if (this.__recipientPolicies && this.type === 'server') {
|
|
3331
|
+
// A transport `from` that the policy parser rejects would settle every rotated letter as
|
|
3332
|
+
// failed; surface it here instead. A `from` callback is a runtime value and is not checked.
|
|
3333
|
+
if (typeof opts.from === 'string') normalizePolicyAddress(opts.from, 'from');
|
|
3334
|
+
this.transports.forEach((transport, i) => {
|
|
3335
|
+
const from = MailTime.transportFrom(transport);
|
|
3336
|
+
if (from !== void 0) normalizePolicyAddress(from, `transports[${i}].from`);
|
|
3337
|
+
});
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3149
3340
|
this.queue.mailTimeInstance = this;
|
|
3150
3341
|
|
|
3151
3342
|
/** @type {string} */
|
|
@@ -3318,6 +3509,8 @@ class MailTime {
|
|
|
3318
3509
|
|
|
3319
3510
|
this.__isDestroyed = true;
|
|
3320
3511
|
this.__isPaused = false;
|
|
3512
|
+
for (const cancel of [...this.__probeCancels]) cancel();
|
|
3513
|
+
this.__reprobe.clear();
|
|
3321
3514
|
// Queued jobs never started SMTP; dropping them lets a scan blocked on a
|
|
3322
3515
|
// pool slot return, so JoSk shutdown does not wait for in-flight sends.
|
|
3323
3516
|
this.__pool.cancelQueued();
|
|
@@ -3353,14 +3546,34 @@ class MailTime {
|
|
|
3353
3546
|
* @async
|
|
3354
3547
|
* @memberOf MailTime
|
|
3355
3548
|
* @name drain
|
|
3356
|
-
* @description Wait for all in-flight email send attempts to settle
|
|
3357
|
-
* @returns {Promise<
|
|
3549
|
+
* @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.
|
|
3550
|
+
* @returns {Promise<MailTimeDrainResult>}
|
|
3358
3551
|
*/
|
|
3359
3552
|
async drain() {
|
|
3360
3553
|
this.__debug('[drain]');
|
|
3361
3554
|
if (this.__pool) {
|
|
3362
3555
|
await this.__pool.drain();
|
|
3363
3556
|
}
|
|
3557
|
+
return { failedWrites: this.__failedWrites };
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
/**
|
|
3561
|
+
* @internal
|
|
3562
|
+
* @memberOf MailTime
|
|
3563
|
+
* @name ___reportWriteFailure
|
|
3564
|
+
* @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.
|
|
3565
|
+
* @param {MailTimeTask} task
|
|
3566
|
+
* @param {unknown} error
|
|
3567
|
+
* @param {'complete' | 'checkpoint'} [phase]
|
|
3568
|
+
* @returns {void}
|
|
3569
|
+
*/
|
|
3570
|
+
___reportWriteFailure(task, error, phase = 'complete') {
|
|
3571
|
+
this.__failedWrites++;
|
|
3572
|
+
if (this.__abortInFlight) {
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
logError(`[private send] completion error, ${phase} write failed; row ${task?.uuid} may stay claimed and be re-sent after sendingTimeout`, error);
|
|
3576
|
+
callHook('onError', this.onError, error, task || null, { phase, attempt: task?.tries, transportIndex: task?.transport });
|
|
3364
3577
|
}
|
|
3365
3578
|
|
|
3366
3579
|
/**
|
|
@@ -3465,6 +3678,10 @@ class MailTime {
|
|
|
3465
3678
|
throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
|
|
3466
3679
|
}
|
|
3467
3680
|
|
|
3681
|
+
if (this.__recipientPolicies) {
|
|
3682
|
+
validatePolicyMailOptions(mailOptions);
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3468
3685
|
if (this.concatEmails) {
|
|
3469
3686
|
sendAt = sendAt + this.concatDelay;
|
|
3470
3687
|
const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
|
|
@@ -3546,21 +3763,15 @@ class MailTime {
|
|
|
3546
3763
|
if (task.tries >= this.maxTries) {
|
|
3547
3764
|
this.___finalizeRejected(task, info);
|
|
3548
3765
|
|
|
3549
|
-
const
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
let finalized = false;
|
|
3559
|
-
if (!this.keepHistory) {
|
|
3560
|
-
finalized = await this.queue.remove(task, leaseRemove);
|
|
3561
|
-
} else {
|
|
3562
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
3563
|
-
}
|
|
3766
|
+
const finalized = await this.___outcomeWrite(task, (t) => (!this.keepHistory
|
|
3767
|
+
? this.queue.remove(t, __leaseRemoveOpts(t))
|
|
3768
|
+
: this.queue.update(t, __withLease(t, {
|
|
3769
|
+
isSent: false,
|
|
3770
|
+
isFailed: true,
|
|
3771
|
+
isSending: false,
|
|
3772
|
+
sendingAt: 0,
|
|
3773
|
+
mailOptions: task.mailOptions,
|
|
3774
|
+
}))));
|
|
3564
3775
|
|
|
3565
3776
|
if (!finalized) {
|
|
3566
3777
|
this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
|
|
@@ -3586,12 +3797,12 @@ class MailTime {
|
|
|
3586
3797
|
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
3587
3798
|
}
|
|
3588
3799
|
|
|
3589
|
-
const released = await this.queue.update(
|
|
3800
|
+
const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
|
|
3590
3801
|
isSending: false,
|
|
3591
3802
|
sendingAt: 0,
|
|
3592
3803
|
sendAt: Date.now() + this.retryDelay,
|
|
3593
3804
|
transport: transportIndex,
|
|
3594
|
-
}));
|
|
3805
|
+
})));
|
|
3595
3806
|
|
|
3596
3807
|
if (!released) {
|
|
3597
3808
|
this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
|
|
@@ -3993,6 +4204,10 @@ class MailTime {
|
|
|
3993
4204
|
}));
|
|
3994
4205
|
} catch (renewError) {
|
|
3995
4206
|
logError('[private renewClaim] storage error during claim renewal', renewError);
|
|
4207
|
+
// Storage may have applied the write before the driver failed; remember the stamp
|
|
4208
|
+
// so the outcome write can retry with it (see ___outcomeWrite).
|
|
4209
|
+
// A stale claim is remembered as null: the lost outcome write is reported, never retried.
|
|
4210
|
+
this.__uncertainRenewals.set(task, task.sendingAt > renewedAt - this.sendingTimeout ? renewedAt : null);
|
|
3996
4211
|
halt();
|
|
3997
4212
|
return;
|
|
3998
4213
|
}
|
|
@@ -4023,6 +4238,38 @@ class MailTime {
|
|
|
4023
4238
|
return { stop };
|
|
4024
4239
|
}
|
|
4025
4240
|
|
|
4241
|
+
/**
|
|
4242
|
+
* @async
|
|
4243
|
+
* @internal
|
|
4244
|
+
* @memberOf MailTime
|
|
4245
|
+
* @name ___outcomeWrite
|
|
4246
|
+
* @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.
|
|
4247
|
+
* @param {MailTimeTask} task
|
|
4248
|
+
* @param {(t: MailTimeTask) => Promise<boolean>} write
|
|
4249
|
+
* @returns {Promise<boolean>}
|
|
4250
|
+
*/
|
|
4251
|
+
async ___outcomeWrite(task, write) {
|
|
4252
|
+
if (await write(task)) {
|
|
4253
|
+
this.__uncertainRenewals.delete(task);
|
|
4254
|
+
return true;
|
|
4255
|
+
}
|
|
4256
|
+
const attempted = this.__uncertainRenewals.get(task);
|
|
4257
|
+
if (attempted === void 0) {
|
|
4258
|
+
return false;
|
|
4259
|
+
}
|
|
4260
|
+
this.__uncertainRenewals.delete(task);
|
|
4261
|
+
const lost = new Error('outcome write lost (renewal outcome uncertain or lease taken over)');
|
|
4262
|
+
if (attempted === null || this.__abortInFlight) {
|
|
4263
|
+
this.___reportWriteFailure(task, lost, 'complete');
|
|
4264
|
+
return false;
|
|
4265
|
+
}
|
|
4266
|
+
if (await write({ ...task, sendingAt: attempted })) {
|
|
4267
|
+
return true;
|
|
4268
|
+
}
|
|
4269
|
+
this.___reportWriteFailure(task, lost, 'complete');
|
|
4270
|
+
return false;
|
|
4271
|
+
}
|
|
4272
|
+
|
|
4026
4273
|
/** @internal Serialize every policy checkpoint with this attempt's claim renewal. */
|
|
4027
4274
|
async ___sendWithRecipientPolicies(task) {
|
|
4028
4275
|
if (!task || task.isSent || task.isFailed || task.isCancelled || task.isSettled || this.__abortInFlight) return;
|
|
@@ -4037,9 +4284,12 @@ class MailTime {
|
|
|
4037
4284
|
}
|
|
4038
4285
|
Object.assign(task, fields);
|
|
4039
4286
|
const lease = new RecipientPolicyLease({
|
|
4040
|
-
task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals,
|
|
4287
|
+
task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals, sendingTimeout: this.sendingTimeout,
|
|
4041
4288
|
shouldAbort: () => this.__abortInFlight,
|
|
4042
|
-
report: (error) =>
|
|
4289
|
+
report: (error, phase) => {
|
|
4290
|
+
if (phase === 'complete' || phase === 'checkpoint') this.___reportWriteFailure(task, error, phase);
|
|
4291
|
+
else if (!this.__abortInFlight) logError('[recipientPolicies] claim renewal write failed; renewals stop, the outcome write still runs', error);
|
|
4292
|
+
},
|
|
4043
4293
|
});
|
|
4044
4294
|
try {
|
|
4045
4295
|
if (!lease.active) return;
|
|
@@ -4095,7 +4345,15 @@ class MailTime {
|
|
|
4095
4345
|
context.recipients = context.recipients.filter((r) => eligible.has(r.address));
|
|
4096
4346
|
await this.___attemptPolicyTransport(task, lease, compiled, context);
|
|
4097
4347
|
} catch (error) {
|
|
4098
|
-
if (lease.active)
|
|
4348
|
+
if (!lease.active) return;
|
|
4349
|
+
if (isAddressError(error)) {
|
|
4350
|
+
// Unparseable addresses fail identically on every attempt, so settle now instead of
|
|
4351
|
+
// spending the retry budget. The message names the field, never the address.
|
|
4352
|
+
if (!this.__abortInFlight) logError(`[recipientPolicies] task ${task.uuid} failed without retry:`, error.message);
|
|
4353
|
+
await this.___completePolicyTask(task, lease, error, void 0, true);
|
|
4354
|
+
} else {
|
|
4355
|
+
await this.___retryPolicyTask(task, lease, error, void 0, true, true);
|
|
4356
|
+
}
|
|
4099
4357
|
} finally {
|
|
4100
4358
|
await lease.stop();
|
|
4101
4359
|
}
|
|
@@ -4104,7 +4362,7 @@ class MailTime {
|
|
|
4104
4362
|
/** @internal Persist SMTP acceptance before awaiting rejection classification or observation. */
|
|
4105
4363
|
async ___attemptPolicyTransport(task, lease, compiled, context) {
|
|
4106
4364
|
const transport = this.transports[context.transport.index];
|
|
4107
|
-
const outgoing = { ...compiled, envelope: context.envelope };
|
|
4365
|
+
const outgoing = { ...rewritePolicyHeaders(compiled), envelope: context.envelope };
|
|
4108
4366
|
const { error, info } = await new Promise((resolve) => {
|
|
4109
4367
|
let called = false;
|
|
4110
4368
|
const done = (error, info) => {
|
|
@@ -4312,6 +4570,7 @@ class MailTime {
|
|
|
4312
4570
|
task.tries = tries;
|
|
4313
4571
|
task.isSending = true;
|
|
4314
4572
|
task.sendingAt = sendingAt;
|
|
4573
|
+
this.__uncertainRenewals.delete(task);
|
|
4315
4574
|
|
|
4316
4575
|
let transportIndex = task.transport;
|
|
4317
4576
|
if (!this.___isHealthyTransport(transportIndex)) {
|
|
@@ -4365,20 +4624,14 @@ class MailTime {
|
|
|
4365
4624
|
if (isFullyDelivered) {
|
|
4366
4625
|
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
4367
4626
|
|
|
4368
|
-
const
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
let completed = false;
|
|
4377
|
-
if (!this.keepHistory) {
|
|
4378
|
-
completed = await this.queue.remove(task, leaseRemove);
|
|
4379
|
-
} else {
|
|
4380
|
-
completed = await this.queue.update(task, leaseUpdate);
|
|
4381
|
-
}
|
|
4627
|
+
const completed = await this.___outcomeWrite(task, (t) => (!this.keepHistory
|
|
4628
|
+
? this.queue.remove(t, __leaseRemoveOpts(t))
|
|
4629
|
+
: this.queue.update(t, __withLease(t, {
|
|
4630
|
+
isSent: true,
|
|
4631
|
+
isSending: false,
|
|
4632
|
+
sendingAt: 0,
|
|
4633
|
+
mailOptions: task.mailOptions,
|
|
4634
|
+
}))));
|
|
4382
4635
|
|
|
4383
4636
|
if (!completed) {
|
|
4384
4637
|
this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
|
|
@@ -4396,21 +4649,15 @@ class MailTime {
|
|
|
4396
4649
|
if (task.tries >= this.maxTries) {
|
|
4397
4650
|
this.___finalizeRejected(task, info);
|
|
4398
4651
|
|
|
4399
|
-
const
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
let finalized = false;
|
|
4409
|
-
if (!this.keepHistory) {
|
|
4410
|
-
finalized = await this.queue.remove(task, leaseRemove);
|
|
4411
|
-
} else {
|
|
4412
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
4413
|
-
}
|
|
4652
|
+
const finalized = await this.___outcomeWrite(task, (t) => (!this.keepHistory
|
|
4653
|
+
? this.queue.remove(t, __leaseRemoveOpts(t))
|
|
4654
|
+
: this.queue.update(t, __withLease(t, {
|
|
4655
|
+
isSent: false,
|
|
4656
|
+
isFailed: true,
|
|
4657
|
+
isSending: false,
|
|
4658
|
+
sendingAt: 0,
|
|
4659
|
+
mailOptions: task.mailOptions,
|
|
4660
|
+
}))));
|
|
4414
4661
|
|
|
4415
4662
|
if (!finalized) {
|
|
4416
4663
|
this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
|
|
@@ -4435,19 +4682,19 @@ class MailTime {
|
|
|
4435
4682
|
}
|
|
4436
4683
|
|
|
4437
4684
|
const nextSendAt = Date.now() + this.retryDelay;
|
|
4438
|
-
const released = await this.queue.update(
|
|
4685
|
+
const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
|
|
4439
4686
|
isSending: false,
|
|
4440
4687
|
sendingAt: 0,
|
|
4441
4688
|
sendAt: nextSendAt,
|
|
4442
4689
|
mailOptions: task.mailOptions,
|
|
4443
|
-
}));
|
|
4690
|
+
})));
|
|
4444
4691
|
if (!released) {
|
|
4445
4692
|
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
4446
4693
|
return;
|
|
4447
4694
|
}
|
|
4448
4695
|
this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
|
|
4449
4696
|
} catch (completionError) {
|
|
4450
|
-
|
|
4697
|
+
this.___reportWriteFailure(task, completionError);
|
|
4451
4698
|
} finally {
|
|
4452
4699
|
resolve();
|
|
4453
4700
|
}
|
|
@@ -4463,7 +4710,11 @@ class MailTime {
|
|
|
4463
4710
|
return;
|
|
4464
4711
|
}
|
|
4465
4712
|
logError('Exception during runtime:', e);
|
|
4466
|
-
|
|
4713
|
+
try {
|
|
4714
|
+
await this.___handleError(task, e, {});
|
|
4715
|
+
} catch (completionError) {
|
|
4716
|
+
this.___reportWriteFailure(task, completionError);
|
|
4717
|
+
}
|
|
4467
4718
|
}
|
|
4468
4719
|
}
|
|
4469
4720
|
|
|
@@ -4525,51 +4776,163 @@ class MailTime {
|
|
|
4525
4776
|
* @internal
|
|
4526
4777
|
* @memberOf MailTime
|
|
4527
4778
|
* @name ___verifyTransports
|
|
4528
|
-
* @description Probe each transport's `verify()` once at startup.
|
|
4779
|
+
* @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.
|
|
4529
4780
|
* @returns {Promise<void>}
|
|
4530
4781
|
*/
|
|
4531
4782
|
async ___verifyTransports() {
|
|
4532
4783
|
this.__debug('[private verifyTransports]');
|
|
4533
|
-
|
|
4784
|
+
await Promise.all(this.transports.map(async (transport, index) => {
|
|
4534
4785
|
if (!transport || typeof transport.verify !== 'function') {
|
|
4535
|
-
return
|
|
4786
|
+
return;
|
|
4787
|
+
}
|
|
4788
|
+
const outcome = await this.___verifyOne(transport, index);
|
|
4789
|
+
if (outcome === 'timeout' && !this.__isDestroyed) {
|
|
4790
|
+
console.warn(`[WARN] [mail-time] [verifyTransports] transport #${index} verify() gave no verdict within ${this.verifyTimeout}ms; keeping it usable, a late verdict still applies`);
|
|
4791
|
+
}
|
|
4792
|
+
}));
|
|
4793
|
+
|
|
4794
|
+
if (this.__unhealthyTransports.size === this.transports.length) {
|
|
4795
|
+
throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
|
|
4796
|
+
}
|
|
4797
|
+
}
|
|
4798
|
+
|
|
4799
|
+
/**
|
|
4800
|
+
* @internal
|
|
4801
|
+
* @memberOf MailTime
|
|
4802
|
+
* @name ___verifyOne
|
|
4803
|
+
* @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).
|
|
4804
|
+
* @param {MailTimeTransport} transport
|
|
4805
|
+
* @param {number} index
|
|
4806
|
+
* @returns {Promise<'ok' | 'fail' | 'timeout'>}
|
|
4807
|
+
*/
|
|
4808
|
+
___verifyOne(transport, index) {
|
|
4809
|
+
const gen = (this.__verifyGen[index] || 0) + 1;
|
|
4810
|
+
this.__verifyGen[index] = gen;
|
|
4811
|
+
return new Promise((resolve) => {
|
|
4812
|
+
let decided = false;
|
|
4813
|
+
let waiting = true;
|
|
4814
|
+
const stopWaiting = (outcome) => {
|
|
4815
|
+
if (!waiting) {
|
|
4816
|
+
return;
|
|
4817
|
+
}
|
|
4818
|
+
waiting = false;
|
|
4819
|
+
clearTimeout(timer);
|
|
4820
|
+
this.__probeCancels.delete(cancel);
|
|
4821
|
+
resolve(outcome);
|
|
4822
|
+
};
|
|
4823
|
+
const cancel = () => stopWaiting('timeout');
|
|
4824
|
+
const timer = setTimeout(cancel, this.verifyTimeout);
|
|
4825
|
+
if (typeof timer.unref === 'function') {
|
|
4826
|
+
timer.unref();
|
|
4536
4827
|
}
|
|
4828
|
+
this.__probeCancels.add(cancel);
|
|
4829
|
+
|
|
4830
|
+
const decide = (error) => {
|
|
4831
|
+
if (decided) {
|
|
4832
|
+
return;
|
|
4833
|
+
}
|
|
4834
|
+
decided = true;
|
|
4835
|
+
this.___applyVerdict(index, gen, error);
|
|
4836
|
+
stopWaiting(error ? 'fail' : 'ok');
|
|
4837
|
+
};
|
|
4838
|
+
|
|
4537
4839
|
try {
|
|
4538
|
-
|
|
4539
|
-
|
|
4840
|
+
const returned = transport.verify((error) => decide(error || void 0));
|
|
4841
|
+
if (returned && typeof returned.then === 'function') {
|
|
4842
|
+
returned.then(() => decide(), (error) => decide(error || new Error('[mail-time] [verifyTransports] verify() rejected')));
|
|
4843
|
+
} else if (returned === true || returned === false) {
|
|
4844
|
+
decide();
|
|
4845
|
+
}
|
|
4540
4846
|
} catch (error) {
|
|
4541
|
-
|
|
4847
|
+
decide(error || new Error('[mail-time] [verifyTransports] verify() threw'));
|
|
4542
4848
|
}
|
|
4543
|
-
})
|
|
4849
|
+
});
|
|
4850
|
+
}
|
|
4544
4851
|
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4852
|
+
/**
|
|
4853
|
+
* @internal
|
|
4854
|
+
* @memberOf MailTime
|
|
4855
|
+
* @name ___applyVerdict
|
|
4856
|
+
* @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.
|
|
4857
|
+
* @param {number} index
|
|
4858
|
+
* @param {number} gen
|
|
4859
|
+
* @param {unknown} [error]
|
|
4860
|
+
* @returns {void}
|
|
4861
|
+
*/
|
|
4862
|
+
___applyVerdict(index, gen, error) {
|
|
4863
|
+
if (this.__isDestroyed || this.__verifyGen[index] !== gen) {
|
|
4864
|
+
return;
|
|
4865
|
+
}
|
|
4866
|
+
const quarantined = this.__unhealthyTransports.has(index);
|
|
4867
|
+
if (!error) {
|
|
4868
|
+
if (!quarantined) {
|
|
4869
|
+
return;
|
|
4870
|
+
}
|
|
4871
|
+
this.__unhealthyTransports.delete(index);
|
|
4872
|
+
this.__reprobe.delete(index);
|
|
4873
|
+
if (this.strategy === 'backup' && index < this.transport) {
|
|
4874
|
+
this.transport = index;
|
|
4548
4875
|
}
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
|
|
4876
|
+
console.log(`[mail-time] [verifyTransports] transport #${index} recovered; back in rotation`);
|
|
4877
|
+
return;
|
|
4552
4878
|
}
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4879
|
+
if (quarantined) {
|
|
4880
|
+
const st = this.__reprobe.get(index);
|
|
4881
|
+
if (st) {
|
|
4882
|
+
st.inFlight = false;
|
|
4883
|
+
st.count++;
|
|
4884
|
+
st.nextProbeAt = Date.now() + Math.min(REPROBE_BASE_MS * Math.pow(2, st.count), REPROBE_MAX_MS);
|
|
4885
|
+
this.__debug(`[private reprobe] transport #${index} failed, next probe at ${new Date(st.nextProbeAt)}`);
|
|
4886
|
+
}
|
|
4887
|
+
return;
|
|
4888
|
+
}
|
|
4889
|
+
this.__unhealthyTransports.add(index);
|
|
4890
|
+
this.__reprobe.set(index, { count: 0, nextProbeAt: Date.now() + REPROBE_BASE_MS, inFlight: false });
|
|
4891
|
+
if (this.transport === index && this.__unhealthyTransports.size < this.transports.length) {
|
|
4892
|
+
this.transport = this.___nextHealthyTransport(index);
|
|
4556
4893
|
}
|
|
4894
|
+
logError(`[mail-time] [verifyTransports] transport #${index} failed verification`, error);
|
|
4895
|
+
callHook('onError', this.onError, error, null, { transportIndex: index, phase: 'verify' });
|
|
4896
|
+
}
|
|
4557
4897
|
|
|
4558
|
-
|
|
4559
|
-
|
|
4898
|
+
/**
|
|
4899
|
+
* @internal
|
|
4900
|
+
* @memberOf MailTime
|
|
4901
|
+
* @name ___maybeReprobe
|
|
4902
|
+
* @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()`.
|
|
4903
|
+
* @param {number} index
|
|
4904
|
+
* @returns {void}
|
|
4905
|
+
*/
|
|
4906
|
+
___maybeReprobe(index) {
|
|
4907
|
+
if (this.__isDestroyed || this.type !== 'server' || !this.verifyTransports) {
|
|
4908
|
+
return;
|
|
4560
4909
|
}
|
|
4910
|
+
const st = this.__reprobe.get(index);
|
|
4911
|
+
const transport = this.transports[index];
|
|
4912
|
+
if (!st || st.inFlight || st.nextProbeAt > Date.now() || !transport || typeof transport.verify !== 'function') {
|
|
4913
|
+
return;
|
|
4914
|
+
}
|
|
4915
|
+
st.inFlight = true;
|
|
4916
|
+
this.__debug(`[private reprobe] transport #${index}, attempt after ${st.count} failure(s)`);
|
|
4917
|
+
// The verdict, not the timeout, releases `inFlight` (see ___applyVerdict), so a hung
|
|
4918
|
+
// verify() never overlaps with a new probe on the same transport.
|
|
4919
|
+
this.___verifyOne(transport, index);
|
|
4561
4920
|
}
|
|
4562
4921
|
|
|
4563
4922
|
/**
|
|
4564
4923
|
* @internal
|
|
4565
4924
|
* @memberOf MailTime
|
|
4566
4925
|
* @name ___isHealthyTransport
|
|
4567
|
-
* @description Return true when the transport at `index`
|
|
4926
|
+
* @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.
|
|
4568
4927
|
* @param {number} index
|
|
4569
4928
|
* @returns {boolean}
|
|
4570
4929
|
*/
|
|
4571
4930
|
___isHealthyTransport(index) {
|
|
4572
|
-
|
|
4931
|
+
if (!this.__unhealthyTransports.has(index)) {
|
|
4932
|
+
return true;
|
|
4933
|
+
}
|
|
4934
|
+
this.___maybeReprobe(index);
|
|
4935
|
+
return false;
|
|
4573
4936
|
}
|
|
4574
4937
|
|
|
4575
4938
|
/**
|