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.cjs
CHANGED
|
@@ -524,7 +524,7 @@ class MongoQueue {
|
|
|
524
524
|
}
|
|
525
525
|
|
|
526
526
|
try {
|
|
527
|
-
while (await cursor.hasNext()) {
|
|
527
|
+
while (!this.mailTimeInstance.___isStopped && await cursor.hasNext()) {
|
|
528
528
|
await this.mailTimeInstance.___dispatch(await cursor.next());
|
|
529
529
|
}
|
|
530
530
|
} finally {
|
|
@@ -1214,6 +1214,9 @@ class RedisQueue {
|
|
|
1214
1214
|
return;
|
|
1215
1215
|
}
|
|
1216
1216
|
for (const value of candidates) {
|
|
1217
|
+
if (this.mailTimeInstance.___isStopped) {
|
|
1218
|
+
break;
|
|
1219
|
+
}
|
|
1217
1220
|
const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
|
|
1218
1221
|
if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
|
|
1219
1222
|
await this.mailTimeInstance.___dispatch(candidate);
|
|
@@ -1234,6 +1237,9 @@ class RedisQueue {
|
|
|
1234
1237
|
for await (const cursorValue of cursor) {
|
|
1235
1238
|
const sendatKeys = Array.isArray(cursorValue) ? cursorValue : [cursorValue];
|
|
1236
1239
|
for (const sendatKey of sendatKeys) {
|
|
1240
|
+
if (this.mailTimeInstance.___isStopped) {
|
|
1241
|
+
break outer;
|
|
1242
|
+
}
|
|
1237
1243
|
const raw = await this.client.get(sendatKey);
|
|
1238
1244
|
if (raw === null || parseInt(raw, 10) > now) {
|
|
1239
1245
|
continue;
|
|
@@ -1964,6 +1970,9 @@ class PostgresQueue {
|
|
|
1964
1970
|
LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
|
|
1965
1971
|
|
|
1966
1972
|
for (const row of res.rows || []) {
|
|
1973
|
+
if (this.mailTimeInstance.___isStopped) {
|
|
1974
|
+
break;
|
|
1975
|
+
}
|
|
1967
1976
|
await this.mailTimeInstance.___dispatch(normalizeRow(row));
|
|
1968
1977
|
}
|
|
1969
1978
|
} catch (iterateError) {
|
|
@@ -2305,12 +2314,12 @@ const PRESETS = Object.freeze({
|
|
|
2305
2314
|
transactional: Object.freeze({
|
|
2306
2315
|
concatEmails: false,
|
|
2307
2316
|
retries: 30,
|
|
2308
|
-
retryDelay:
|
|
2317
|
+
retryDelay: 10000,
|
|
2309
2318
|
mode: 'batch',
|
|
2310
2319
|
concurrency: 1,
|
|
2311
2320
|
onError: defaultPresetOnError,
|
|
2312
2321
|
josk: Object.freeze({
|
|
2313
|
-
zombieTime:
|
|
2322
|
+
zombieTime: 120000,
|
|
2314
2323
|
}),
|
|
2315
2324
|
}),
|
|
2316
2325
|
otp: Object.freeze({
|
|
@@ -2318,52 +2327,52 @@ const PRESETS = Object.freeze({
|
|
|
2318
2327
|
retries: 5,
|
|
2319
2328
|
retryDelay: 2000,
|
|
2320
2329
|
revolvingInterval: 1024,
|
|
2321
|
-
sendingTimeout:
|
|
2330
|
+
sendingTimeout: 120000,
|
|
2322
2331
|
mode: 'batch',
|
|
2323
2332
|
concurrency: 4,
|
|
2324
2333
|
onError: defaultPresetOnError,
|
|
2325
2334
|
josk: Object.freeze({
|
|
2326
2335
|
minRevolvingDelay: 256,
|
|
2327
2336
|
maxRevolvingDelay: 1024,
|
|
2328
|
-
zombieTime:
|
|
2337
|
+
zombieTime: 60000,
|
|
2329
2338
|
}),
|
|
2330
2339
|
}),
|
|
2331
2340
|
newsletter: Object.freeze({
|
|
2332
2341
|
concatEmails: true,
|
|
2333
|
-
concatDelay: 5 *
|
|
2342
|
+
concatDelay: 5 * 60000,
|
|
2334
2343
|
concatSubject: 'Your updates',
|
|
2335
2344
|
retries: 5,
|
|
2336
|
-
retryDelay:
|
|
2337
|
-
sendingTimeout:
|
|
2345
|
+
retryDelay: 60000,
|
|
2346
|
+
sendingTimeout: 600000,
|
|
2338
2347
|
mode: 'batch',
|
|
2339
2348
|
concurrency: 2,
|
|
2340
2349
|
onError: defaultPresetOnError,
|
|
2341
2350
|
josk: Object.freeze({
|
|
2342
|
-
zombieTime:
|
|
2351
|
+
zombieTime: 300000,
|
|
2343
2352
|
}),
|
|
2344
2353
|
}),
|
|
2345
2354
|
marketing: Object.freeze({
|
|
2346
2355
|
concatEmails: false,
|
|
2347
2356
|
retries: 10,
|
|
2348
|
-
retryDelay:
|
|
2357
|
+
retryDelay: 30000,
|
|
2349
2358
|
mode: 'batch',
|
|
2350
2359
|
concurrency: 5,
|
|
2351
2360
|
onError: defaultPresetOnError,
|
|
2352
2361
|
josk: Object.freeze({
|
|
2353
|
-
zombieTime:
|
|
2362
|
+
zombieTime: 180000,
|
|
2354
2363
|
}),
|
|
2355
2364
|
}),
|
|
2356
2365
|
notifications: Object.freeze({
|
|
2357
2366
|
concatEmails: true,
|
|
2358
|
-
concatDelay:
|
|
2367
|
+
concatDelay: 60000,
|
|
2359
2368
|
concatSubject: 'New activity',
|
|
2360
2369
|
retries: 8,
|
|
2361
|
-
retryDelay:
|
|
2370
|
+
retryDelay: 30000,
|
|
2362
2371
|
mode: 'batch',
|
|
2363
2372
|
concurrency: 3,
|
|
2364
2373
|
onError: defaultPresetOnError,
|
|
2365
2374
|
josk: Object.freeze({
|
|
2366
|
-
zombieTime:
|
|
2375
|
+
zombieTime: 180000,
|
|
2367
2376
|
}),
|
|
2368
2377
|
}),
|
|
2369
2378
|
alerts: Object.freeze({
|
|
@@ -2371,14 +2380,14 @@ const PRESETS = Object.freeze({
|
|
|
2371
2380
|
retries: 20,
|
|
2372
2381
|
retryDelay: 5000,
|
|
2373
2382
|
revolvingInterval: 1024,
|
|
2374
|
-
sendingTimeout:
|
|
2383
|
+
sendingTimeout: 120000,
|
|
2375
2384
|
mode: 'batch',
|
|
2376
2385
|
concurrency: 2,
|
|
2377
2386
|
onError: defaultPresetOnError,
|
|
2378
2387
|
josk: Object.freeze({
|
|
2379
2388
|
minRevolvingDelay: 256,
|
|
2380
2389
|
maxRevolvingDelay: 1024,
|
|
2381
|
-
zombieTime:
|
|
2390
|
+
zombieTime: 60000,
|
|
2382
2391
|
}),
|
|
2383
2392
|
}),
|
|
2384
2393
|
});
|
|
@@ -2437,29 +2446,113 @@ const mailTimePreset = (name, overrides) => {
|
|
|
2437
2446
|
|
|
2438
2447
|
const policyError = (message) => new Error(`[mail-time] [recipientPolicies] ${message}`);
|
|
2439
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;
|
|
2440
2452
|
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
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
|
+
}
|
|
2445
2515
|
}
|
|
2446
|
-
|
|
2447
|
-
if (
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
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 };
|
|
2451
2521
|
}
|
|
2452
|
-
|
|
2453
|
-
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);
|
|
2454
2545
|
};
|
|
2455
2546
|
|
|
2456
2547
|
const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
2457
2548
|
const recipients = new Map();
|
|
2458
2549
|
const explicit = compiled.envelope && hasOwnProp(compiled.envelope, 'to');
|
|
2459
|
-
const add = (value, source, authoritative) => {
|
|
2460
|
-
|
|
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++) {
|
|
2461
2554
|
let address;
|
|
2462
|
-
try { address = normalizePolicyAddress(
|
|
2555
|
+
try { address = normalizePolicyAddress(entries[i], list ? `${label}[${i}]` : label); }
|
|
2463
2556
|
catch (error) { if (authoritative) throw error; else continue; }
|
|
2464
2557
|
if (!recipients.has(address)) {
|
|
2465
2558
|
if (!authoritative) continue;
|
|
@@ -2469,7 +2562,7 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
|
2469
2562
|
if (!sources.includes(source)) sources.push(source);
|
|
2470
2563
|
}
|
|
2471
2564
|
};
|
|
2472
|
-
if (explicit) add(compiled.envelope.to, 'envelope', true);
|
|
2565
|
+
if (explicit) add(compiled.envelope.to, 'envelope', true, 'envelope.to');
|
|
2473
2566
|
for (const source of ['to', 'cc', 'bcc']) {
|
|
2474
2567
|
if (hasOwnProp(compiled, source) && compiled[source] !== void 0) add(compiled[source], source, !explicit);
|
|
2475
2568
|
}
|
|
@@ -2479,14 +2572,61 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
|
2479
2572
|
}
|
|
2480
2573
|
const envelope = { to: [...recipients.keys()] };
|
|
2481
2574
|
if (compiled.envelope && hasOwnProp(compiled.envelope, 'from')) {
|
|
2482
|
-
envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from);
|
|
2575
|
+
envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from, 'envelope.from');
|
|
2483
2576
|
} else {
|
|
2484
|
-
|
|
2485
|
-
|
|
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);
|
|
2486
2580
|
}
|
|
2487
2581
|
return { envelope, recipients: [...recipients.values()] };
|
|
2488
2582
|
};
|
|
2489
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
|
+
|
|
2490
2630
|
const validateRecipientPolicies = (value, queue) => {
|
|
2491
2631
|
if (value === void 0) return null;
|
|
2492
2632
|
if (!Array.isArray(value) || !value.length) throw policyError('recipientPolicies must be a nonempty array');
|
|
@@ -2523,7 +2663,7 @@ const validatePolicyResult = (raw, hook, context) => {
|
|
|
2523
2663
|
const decisions = new Map();
|
|
2524
2664
|
for (const item of raw.decisions) {
|
|
2525
2665
|
if (!isPlainObject(item)) throw policyError('decision must be an object');
|
|
2526
|
-
const address = normalizePolicyAddress(item.address);
|
|
2666
|
+
const address = normalizePolicyAddress(item.address, 'decision.address');
|
|
2527
2667
|
if (!batch.has(address)) throw policyError('decision address is outside the input batch');
|
|
2528
2668
|
const allowed = hook === 'beforeSend' ? item.status === 'suppressed' : (item.status === 'retry' || item.status === 'rejected');
|
|
2529
2669
|
if (!allowed) throw policyError('invalid decision status for this phase');
|
|
@@ -2657,7 +2797,11 @@ const normalizeRejections = (error, info, transport) => {
|
|
|
2657
2797
|
};
|
|
2658
2798
|
|
|
2659
2799
|
class RecipientPolicyLease {
|
|
2660
|
-
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;
|
|
2661
2805
|
this.__task = task;
|
|
2662
2806
|
this.__queue = queue;
|
|
2663
2807
|
this.__shouldAbort = shouldAbort;
|
|
@@ -2665,6 +2809,8 @@ class RecipientPolicyLease {
|
|
|
2665
2809
|
this.__closed = false;
|
|
2666
2810
|
this.__finishing = false;
|
|
2667
2811
|
this.__tail = Promise.resolve();
|
|
2812
|
+
this.__renewUncertain = false;
|
|
2813
|
+
this.__attemptedAt = 0;
|
|
2668
2814
|
this.__timer = null;
|
|
2669
2815
|
let pending = false;
|
|
2670
2816
|
let renewals = 0;
|
|
@@ -2679,6 +2825,8 @@ class RecipientPolicyLease {
|
|
|
2679
2825
|
renewals++;
|
|
2680
2826
|
this.__enqueue(async (guard) => {
|
|
2681
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;
|
|
2682
2830
|
return await this.__write(fields, guard);
|
|
2683
2831
|
}).finally(() => { pending = false; });
|
|
2684
2832
|
}, interval);
|
|
@@ -2698,17 +2846,41 @@ class RecipientPolicyLease {
|
|
|
2698
2846
|
this.__closed = true;
|
|
2699
2847
|
}
|
|
2700
2848
|
|
|
2701
|
-
__enqueue(operation) {
|
|
2849
|
+
__enqueue(operation, phase = 'renew') {
|
|
2702
2850
|
const pending = this.__tail.then(async () => {
|
|
2703
2851
|
if (!this.active) return false;
|
|
2704
2852
|
const guard = { leaseTries: this.__task.tries, leaseSendingAt: this.__task.sendingAt };
|
|
2705
2853
|
try {
|
|
2706
|
-
|
|
2707
|
-
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
|
+
}
|
|
2708
2877
|
return ok;
|
|
2709
2878
|
} catch (error) {
|
|
2710
|
-
|
|
2711
|
-
|
|
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);
|
|
2712
2884
|
return false;
|
|
2713
2885
|
}
|
|
2714
2886
|
});
|
|
@@ -2724,7 +2896,7 @@ class RecipientPolicyLease {
|
|
|
2724
2896
|
|
|
2725
2897
|
update(fields) {
|
|
2726
2898
|
if (this.__finishing) return Promise.resolve(false);
|
|
2727
|
-
return this.__enqueue((guard) => this.__write(fields, guard));
|
|
2899
|
+
return this.__enqueue((guard) => this.__write(fields, guard), 'checkpoint');
|
|
2728
2900
|
}
|
|
2729
2901
|
|
|
2730
2902
|
finish(fields, remove) {
|
|
@@ -2736,7 +2908,7 @@ class RecipientPolicyLease {
|
|
|
2736
2908
|
if (ok) Object.assign(this.__task, fields);
|
|
2737
2909
|
this.__halt();
|
|
2738
2910
|
return ok;
|
|
2739
|
-
});
|
|
2911
|
+
}, 'complete');
|
|
2740
2912
|
}
|
|
2741
2913
|
|
|
2742
2914
|
async stop() {
|
|
@@ -2813,11 +2985,19 @@ const createPool = (concurrency) => {
|
|
|
2813
2985
|
let active = 0;
|
|
2814
2986
|
let drainResolvers = [];
|
|
2815
2987
|
|
|
2988
|
+
const settleDrain = () => {
|
|
2989
|
+
if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
|
|
2990
|
+
const resolvers = drainResolvers;
|
|
2991
|
+
drainResolvers = [];
|
|
2992
|
+
for (const r of resolvers) r();
|
|
2993
|
+
}
|
|
2994
|
+
};
|
|
2995
|
+
|
|
2816
2996
|
const tryStart = () => {
|
|
2817
2997
|
while (active < limit && queue.length > 0) {
|
|
2818
2998
|
const job = queue.shift();
|
|
2819
2999
|
active++;
|
|
2820
|
-
job.resolveSlot();
|
|
3000
|
+
job.resolveSlot(true);
|
|
2821
3001
|
Promise.resolve()
|
|
2822
3002
|
.then(job.fn)
|
|
2823
3003
|
.catch((poolError) => {
|
|
@@ -2825,11 +3005,7 @@ const createPool = (concurrency) => {
|
|
|
2825
3005
|
})
|
|
2826
3006
|
.finally(() => {
|
|
2827
3007
|
active--;
|
|
2828
|
-
|
|
2829
|
-
const resolvers = drainResolvers;
|
|
2830
|
-
drainResolvers = [];
|
|
2831
|
-
for (const r of resolvers) r();
|
|
2832
|
-
}
|
|
3008
|
+
settleDrain();
|
|
2833
3009
|
tryStart();
|
|
2834
3010
|
});
|
|
2835
3011
|
}
|
|
@@ -2838,10 +3014,11 @@ const createPool = (concurrency) => {
|
|
|
2838
3014
|
return {
|
|
2839
3015
|
/**
|
|
2840
3016
|
* Queue `fn` for execution under the concurrency limit.
|
|
2841
|
-
* The returned Promise resolves as soon as a slot is acquired and `fn` has started
|
|
3017
|
+
* The returned Promise resolves `true` as soon as a slot is acquired and `fn` has started,
|
|
3018
|
+
* or `false` if `cancelQueued()` dropped the job before it started (`fn` never runs).
|
|
2842
3019
|
* It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
|
|
2843
3020
|
* @param {() => Promise<void>} fn
|
|
2844
|
-
* @returns {Promise<
|
|
3021
|
+
* @returns {Promise<boolean>}
|
|
2845
3022
|
*/
|
|
2846
3023
|
dispatch(fn) {
|
|
2847
3024
|
return new Promise((resolveSlot) => {
|
|
@@ -2849,6 +3026,16 @@ const createPool = (concurrency) => {
|
|
|
2849
3026
|
tryStart();
|
|
2850
3027
|
});
|
|
2851
3028
|
},
|
|
3029
|
+
/**
|
|
3030
|
+
* Drop every job still waiting for a slot. Running jobs are untouched.
|
|
3031
|
+
* @returns {number} dropped jobs
|
|
3032
|
+
*/
|
|
3033
|
+
cancelQueued() {
|
|
3034
|
+
const dropped = queue.splice(0);
|
|
3035
|
+
for (const job of dropped) job.resolveSlot(false);
|
|
3036
|
+
settleDrain();
|
|
3037
|
+
return dropped.length;
|
|
3038
|
+
},
|
|
2852
3039
|
drain() {
|
|
2853
3040
|
if (active === 0 && queue.length === 0) {
|
|
2854
3041
|
return Promise.resolve();
|
|
@@ -2999,9 +3186,21 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
2999
3186
|
*/
|
|
3000
3187
|
|
|
3001
3188
|
/**
|
|
3002
|
-
* @typedef {{
|
|
3189
|
+
* @typedef {{ failedWrites: number }} MailTimeDrainResult
|
|
3003
3190
|
*/
|
|
3004
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
|
|
3199
|
+
*/
|
|
3200
|
+
|
|
3201
|
+
const REPROBE_BASE_MS = 60000;
|
|
3202
|
+
const REPROBE_MAX_MS = 900000;
|
|
3203
|
+
|
|
3005
3204
|
/**
|
|
3006
3205
|
* Class of MailTime.
|
|
3007
3206
|
* With recipientPolicies, terminal callbacks receive grouped recipients and a complete summary after persistence.
|
|
@@ -3100,9 +3299,11 @@ class MailTime {
|
|
|
3100
3299
|
this.__isDestroyed = false;
|
|
3101
3300
|
this.__abortInFlight = false;
|
|
3102
3301
|
this.__isPaused = false;
|
|
3302
|
+
this.__schedulerScans = 0;
|
|
3103
3303
|
this.__readyPromise = null;
|
|
3104
3304
|
this.__schedulerTimer = null;
|
|
3105
3305
|
this.__inFlight = new Set();
|
|
3306
|
+
this.__failedWrites = 0;
|
|
3106
3307
|
this.__pool = createPool(this.concurrency);
|
|
3107
3308
|
|
|
3108
3309
|
this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
|
|
@@ -3110,7 +3311,12 @@ class MailTime {
|
|
|
3110
3311
|
this.transports = Array.isArray(opts.transports) ? opts.transports : [];
|
|
3111
3312
|
this.transport = 0;
|
|
3112
3313
|
this.verifyTransports = opts.verifyTransports !== false;
|
|
3314
|
+
this.verifyTimeout = (typeof opts.verifyTimeout === 'number' && opts.verifyTimeout > 0) ? Math.min(opts.verifyTimeout, 2147483647) : 30000;
|
|
3113
3315
|
this.__unhealthyTransports = new Set();
|
|
3316
|
+
this.__verifyGen = [];
|
|
3317
|
+
this.__reprobe = new Map();
|
|
3318
|
+
this.__probeCancels = new Set();
|
|
3319
|
+
this.__uncertainRenewals = new WeakMap();
|
|
3114
3320
|
|
|
3115
3321
|
if (typeof opts.from === 'string') {
|
|
3116
3322
|
const fromStr = opts.from;
|
|
@@ -3121,6 +3327,16 @@ class MailTime {
|
|
|
3121
3327
|
this.from = false;
|
|
3122
3328
|
}
|
|
3123
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
|
+
|
|
3124
3340
|
this.queue.mailTimeInstance = this;
|
|
3125
3341
|
|
|
3126
3342
|
/** @type {string} */
|
|
@@ -3277,7 +3493,7 @@ class MailTime {
|
|
|
3277
3493
|
/**
|
|
3278
3494
|
* @memberOf MailTime
|
|
3279
3495
|
* @name destroy
|
|
3280
|
-
* @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
|
|
3496
|
+
* @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.
|
|
3281
3497
|
* @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
|
|
3282
3498
|
* @returns {boolean | Promise<boolean>}
|
|
3283
3499
|
*/
|
|
@@ -3293,6 +3509,11 @@ class MailTime {
|
|
|
3293
3509
|
|
|
3294
3510
|
this.__isDestroyed = true;
|
|
3295
3511
|
this.__isPaused = false;
|
|
3512
|
+
for (const cancel of [...this.__probeCancels]) cancel();
|
|
3513
|
+
this.__reprobe.clear();
|
|
3514
|
+
// Queued jobs never started SMTP; dropping them lets a scan blocked on a
|
|
3515
|
+
// pool slot return, so JoSk shutdown does not wait for in-flight sends.
|
|
3516
|
+
this.__pool.cancelQueued();
|
|
3296
3517
|
if (opts?.drain === true) {
|
|
3297
3518
|
return (async () => {
|
|
3298
3519
|
let finished = true;
|
|
@@ -3325,20 +3546,40 @@ class MailTime {
|
|
|
3325
3546
|
* @async
|
|
3326
3547
|
* @memberOf MailTime
|
|
3327
3548
|
* @name drain
|
|
3328
|
-
* @description Wait for all in-flight email send attempts to settle
|
|
3329
|
-
* @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>}
|
|
3330
3551
|
*/
|
|
3331
3552
|
async drain() {
|
|
3332
3553
|
this.__debug('[drain]');
|
|
3333
3554
|
if (this.__pool) {
|
|
3334
3555
|
await this.__pool.drain();
|
|
3335
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 });
|
|
3336
3577
|
}
|
|
3337
3578
|
|
|
3338
3579
|
/**
|
|
3339
3580
|
* @memberOf MailTime
|
|
3340
3581
|
* @name pause
|
|
3341
|
-
* @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();`.
|
|
3582
|
+
* @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();`.
|
|
3342
3583
|
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
3343
3584
|
*/
|
|
3344
3585
|
pause() {
|
|
@@ -3349,6 +3590,9 @@ class MailTime {
|
|
|
3349
3590
|
const paused = this.scheduler.pause();
|
|
3350
3591
|
if (paused) {
|
|
3351
3592
|
this.__isPaused = true;
|
|
3593
|
+
if (this.__schedulerScans > 0) {
|
|
3594
|
+
this.__pool.cancelQueued();
|
|
3595
|
+
}
|
|
3352
3596
|
}
|
|
3353
3597
|
return paused;
|
|
3354
3598
|
}
|
|
@@ -3434,6 +3678,10 @@ class MailTime {
|
|
|
3434
3678
|
throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
|
|
3435
3679
|
}
|
|
3436
3680
|
|
|
3681
|
+
if (this.__recipientPolicies) {
|
|
3682
|
+
validatePolicyMailOptions(mailOptions);
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3437
3685
|
if (this.concatEmails) {
|
|
3438
3686
|
sendAt = sendAt + this.concatDelay;
|
|
3439
3687
|
const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
|
|
@@ -3515,21 +3763,15 @@ class MailTime {
|
|
|
3515
3763
|
if (task.tries >= this.maxTries) {
|
|
3516
3764
|
this.___finalizeRejected(task, info);
|
|
3517
3765
|
|
|
3518
|
-
const
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
let finalized = false;
|
|
3528
|
-
if (!this.keepHistory) {
|
|
3529
|
-
finalized = await this.queue.remove(task, leaseRemove);
|
|
3530
|
-
} else {
|
|
3531
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
3532
|
-
}
|
|
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
|
+
}))));
|
|
3533
3775
|
|
|
3534
3776
|
if (!finalized) {
|
|
3535
3777
|
this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
|
|
@@ -3555,12 +3797,12 @@ class MailTime {
|
|
|
3555
3797
|
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
3556
3798
|
}
|
|
3557
3799
|
|
|
3558
|
-
const released = await this.queue.update(
|
|
3800
|
+
const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
|
|
3559
3801
|
isSending: false,
|
|
3560
3802
|
sendingAt: 0,
|
|
3561
3803
|
sendAt: Date.now() + this.retryDelay,
|
|
3562
3804
|
transport: transportIndex,
|
|
3563
|
-
}));
|
|
3805
|
+
})));
|
|
3564
3806
|
|
|
3565
3807
|
if (!released) {
|
|
3566
3808
|
this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
|
|
@@ -3674,9 +3916,11 @@ class MailTime {
|
|
|
3674
3916
|
compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
|
|
3675
3917
|
}
|
|
3676
3918
|
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3919
|
+
for (const field of ['html', 'text', 'subject']) {
|
|
3920
|
+
if (compiledOpts[field] === void 0 || compiledOpts[field] === null) {
|
|
3921
|
+
compiledOpts[field] = '';
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3680
3924
|
|
|
3681
3925
|
const mailOptionsList = task.mailOptions || [];
|
|
3682
3926
|
const isMulti = mailOptionsList.length > 1;
|
|
@@ -3960,6 +4204,10 @@ class MailTime {
|
|
|
3960
4204
|
}));
|
|
3961
4205
|
} catch (renewError) {
|
|
3962
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);
|
|
3963
4211
|
halt();
|
|
3964
4212
|
return;
|
|
3965
4213
|
}
|
|
@@ -3990,6 +4238,38 @@ class MailTime {
|
|
|
3990
4238
|
return { stop };
|
|
3991
4239
|
}
|
|
3992
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
|
+
|
|
3993
4273
|
/** @internal Serialize every policy checkpoint with this attempt's claim renewal. */
|
|
3994
4274
|
async ___sendWithRecipientPolicies(task) {
|
|
3995
4275
|
if (!task || task.isSent || task.isFailed || task.isCancelled || task.isSettled || this.__abortInFlight) return;
|
|
@@ -4004,9 +4284,12 @@ class MailTime {
|
|
|
4004
4284
|
}
|
|
4005
4285
|
Object.assign(task, fields);
|
|
4006
4286
|
const lease = new RecipientPolicyLease({
|
|
4007
|
-
task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals,
|
|
4287
|
+
task, queue: this.queue, interval: this.renewClaim, maxRenewals: this.maxRenewals, sendingTimeout: this.sendingTimeout,
|
|
4008
4288
|
shouldAbort: () => this.__abortInFlight,
|
|
4009
|
-
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
|
+
},
|
|
4010
4293
|
});
|
|
4011
4294
|
try {
|
|
4012
4295
|
if (!lease.active) return;
|
|
@@ -4062,7 +4345,15 @@ class MailTime {
|
|
|
4062
4345
|
context.recipients = context.recipients.filter((r) => eligible.has(r.address));
|
|
4063
4346
|
await this.___attemptPolicyTransport(task, lease, compiled, context);
|
|
4064
4347
|
} catch (error) {
|
|
4065
|
-
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
|
+
}
|
|
4066
4357
|
} finally {
|
|
4067
4358
|
await lease.stop();
|
|
4068
4359
|
}
|
|
@@ -4071,7 +4362,7 @@ class MailTime {
|
|
|
4071
4362
|
/** @internal Persist SMTP acceptance before awaiting rejection classification or observation. */
|
|
4072
4363
|
async ___attemptPolicyTransport(task, lease, compiled, context) {
|
|
4073
4364
|
const transport = this.transports[context.transport.index];
|
|
4074
|
-
const outgoing = { ...compiled, envelope: context.envelope };
|
|
4365
|
+
const outgoing = { ...rewritePolicyHeaders(compiled), envelope: context.envelope };
|
|
4075
4366
|
const { error, info } = await new Promise((resolve) => {
|
|
4076
4367
|
let called = false;
|
|
4077
4368
|
const done = (error, info) => {
|
|
@@ -4193,12 +4484,12 @@ class MailTime {
|
|
|
4193
4484
|
* @internal
|
|
4194
4485
|
* @memberOf MailTime
|
|
4195
4486
|
* @name ___dispatch
|
|
4196
|
-
* @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.
|
|
4487
|
+
* @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.
|
|
4197
4488
|
* @param {MailTimeTask} task - email's task object from Storage
|
|
4198
4489
|
* @returns {Promise<void 0>}
|
|
4199
4490
|
*/
|
|
4200
4491
|
async ___dispatch(task) {
|
|
4201
|
-
if (this.
|
|
4492
|
+
if (this.___isStopped) {
|
|
4202
4493
|
return;
|
|
4203
4494
|
}
|
|
4204
4495
|
if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
@@ -4209,9 +4500,9 @@ class MailTime {
|
|
|
4209
4500
|
return;
|
|
4210
4501
|
}
|
|
4211
4502
|
this.__inFlight.add(task.uuid);
|
|
4212
|
-
await this.__pool.dispatch(async () => {
|
|
4503
|
+
const started = await this.__pool.dispatch(async () => {
|
|
4213
4504
|
try {
|
|
4214
|
-
if (this.
|
|
4505
|
+
if (this.___isStopped) {
|
|
4215
4506
|
return;
|
|
4216
4507
|
}
|
|
4217
4508
|
await this.___send(task);
|
|
@@ -4219,6 +4510,20 @@ class MailTime {
|
|
|
4219
4510
|
this.__inFlight.delete(task.uuid);
|
|
4220
4511
|
}
|
|
4221
4512
|
});
|
|
4513
|
+
if (!started) {
|
|
4514
|
+
this.__inFlight.delete(task.uuid);
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
|
|
4518
|
+
/**
|
|
4519
|
+
* @internal
|
|
4520
|
+
* @memberOf MailTime
|
|
4521
|
+
* @name ___isStopped
|
|
4522
|
+
* @description `true` after `destroy()`, or while paused during a scheduler-driven scan. Queue adapters check it to end an `iterate` scan early.
|
|
4523
|
+
* @returns {boolean}
|
|
4524
|
+
*/
|
|
4525
|
+
get ___isStopped() {
|
|
4526
|
+
return this.__isDestroyed || (this.__isPaused && this.__schedulerScans > 0);
|
|
4222
4527
|
}
|
|
4223
4528
|
|
|
4224
4529
|
/**
|
|
@@ -4265,6 +4570,7 @@ class MailTime {
|
|
|
4265
4570
|
task.tries = tries;
|
|
4266
4571
|
task.isSending = true;
|
|
4267
4572
|
task.sendingAt = sendingAt;
|
|
4573
|
+
this.__uncertainRenewals.delete(task);
|
|
4268
4574
|
|
|
4269
4575
|
let transportIndex = task.transport;
|
|
4270
4576
|
if (!this.___isHealthyTransport(transportIndex)) {
|
|
@@ -4318,20 +4624,14 @@ class MailTime {
|
|
|
4318
4624
|
if (isFullyDelivered) {
|
|
4319
4625
|
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
4320
4626
|
|
|
4321
|
-
const
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
let completed = false;
|
|
4330
|
-
if (!this.keepHistory) {
|
|
4331
|
-
completed = await this.queue.remove(task, leaseRemove);
|
|
4332
|
-
} else {
|
|
4333
|
-
completed = await this.queue.update(task, leaseUpdate);
|
|
4334
|
-
}
|
|
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
|
+
}))));
|
|
4335
4635
|
|
|
4336
4636
|
if (!completed) {
|
|
4337
4637
|
this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
|
|
@@ -4349,21 +4649,15 @@ class MailTime {
|
|
|
4349
4649
|
if (task.tries >= this.maxTries) {
|
|
4350
4650
|
this.___finalizeRejected(task, info);
|
|
4351
4651
|
|
|
4352
|
-
const
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
let finalized = false;
|
|
4362
|
-
if (!this.keepHistory) {
|
|
4363
|
-
finalized = await this.queue.remove(task, leaseRemove);
|
|
4364
|
-
} else {
|
|
4365
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
4366
|
-
}
|
|
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
|
+
}))));
|
|
4367
4661
|
|
|
4368
4662
|
if (!finalized) {
|
|
4369
4663
|
this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
|
|
@@ -4388,19 +4682,19 @@ class MailTime {
|
|
|
4388
4682
|
}
|
|
4389
4683
|
|
|
4390
4684
|
const nextSendAt = Date.now() + this.retryDelay;
|
|
4391
|
-
const released = await this.queue.update(
|
|
4685
|
+
const released = await this.___outcomeWrite(task, (t) => this.queue.update(t, __withLease(t, {
|
|
4392
4686
|
isSending: false,
|
|
4393
4687
|
sendingAt: 0,
|
|
4394
4688
|
sendAt: nextSendAt,
|
|
4395
4689
|
mailOptions: task.mailOptions,
|
|
4396
|
-
}));
|
|
4690
|
+
})));
|
|
4397
4691
|
if (!released) {
|
|
4398
4692
|
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
4399
4693
|
return;
|
|
4400
4694
|
}
|
|
4401
4695
|
this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
|
|
4402
4696
|
} catch (completionError) {
|
|
4403
|
-
|
|
4697
|
+
this.___reportWriteFailure(task, completionError);
|
|
4404
4698
|
} finally {
|
|
4405
4699
|
resolve();
|
|
4406
4700
|
}
|
|
@@ -4416,7 +4710,11 @@ class MailTime {
|
|
|
4416
4710
|
return;
|
|
4417
4711
|
}
|
|
4418
4712
|
logError('Exception during runtime:', e);
|
|
4419
|
-
|
|
4713
|
+
try {
|
|
4714
|
+
await this.___handleError(task, e, {});
|
|
4715
|
+
} catch (completionError) {
|
|
4716
|
+
this.___reportWriteFailure(task, completionError);
|
|
4717
|
+
}
|
|
4420
4718
|
}
|
|
4421
4719
|
}
|
|
4422
4720
|
|
|
@@ -4433,10 +4731,15 @@ class MailTime {
|
|
|
4433
4731
|
return;
|
|
4434
4732
|
}
|
|
4435
4733
|
const limit = this.mode === 'one' ? 1 : Infinity;
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4734
|
+
this.__schedulerScans++;
|
|
4735
|
+
try {
|
|
4736
|
+
return await this.queue.iterate({
|
|
4737
|
+
limit,
|
|
4738
|
+
sendingTimeout: this.sendingTimeout,
|
|
4739
|
+
});
|
|
4740
|
+
} finally {
|
|
4741
|
+
this.__schedulerScans--;
|
|
4742
|
+
}
|
|
4440
4743
|
}
|
|
4441
4744
|
|
|
4442
4745
|
/**
|
|
@@ -4473,51 +4776,163 @@ class MailTime {
|
|
|
4473
4776
|
* @internal
|
|
4474
4777
|
* @memberOf MailTime
|
|
4475
4778
|
* @name ___verifyTransports
|
|
4476
|
-
* @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.
|
|
4477
4780
|
* @returns {Promise<void>}
|
|
4478
4781
|
*/
|
|
4479
4782
|
async ___verifyTransports() {
|
|
4480
4783
|
this.__debug('[private verifyTransports]');
|
|
4481
|
-
|
|
4784
|
+
await Promise.all(this.transports.map(async (transport, index) => {
|
|
4482
4785
|
if (!transport || typeof transport.verify !== 'function') {
|
|
4483
|
-
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();
|
|
4484
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
|
+
|
|
4485
4839
|
try {
|
|
4486
|
-
|
|
4487
|
-
|
|
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
|
+
}
|
|
4488
4846
|
} catch (error) {
|
|
4489
|
-
|
|
4847
|
+
decide(error || new Error('[mail-time] [verifyTransports] verify() threw'));
|
|
4490
4848
|
}
|
|
4491
|
-
})
|
|
4849
|
+
});
|
|
4850
|
+
}
|
|
4492
4851
|
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
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;
|
|
4496
4875
|
}
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
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;
|
|
4500
4878
|
}
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
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);
|
|
4504
4893
|
}
|
|
4894
|
+
logError(`[mail-time] [verifyTransports] transport #${index} failed verification`, error);
|
|
4895
|
+
callHook('onError', this.onError, error, null, { transportIndex: index, phase: 'verify' });
|
|
4896
|
+
}
|
|
4505
4897
|
|
|
4506
|
-
|
|
4507
|
-
|
|
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;
|
|
4508
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);
|
|
4509
4920
|
}
|
|
4510
4921
|
|
|
4511
4922
|
/**
|
|
4512
4923
|
* @internal
|
|
4513
4924
|
* @memberOf MailTime
|
|
4514
4925
|
* @name ___isHealthyTransport
|
|
4515
|
-
* @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.
|
|
4516
4927
|
* @param {number} index
|
|
4517
4928
|
* @returns {boolean}
|
|
4518
4929
|
*/
|
|
4519
4930
|
___isHealthyTransport(index) {
|
|
4520
|
-
|
|
4931
|
+
if (!this.__unhealthyTransports.has(index)) {
|
|
4932
|
+
return true;
|
|
4933
|
+
}
|
|
4934
|
+
this.___maybeReprobe(index);
|
|
4935
|
+
return false;
|
|
4521
4936
|
}
|
|
4522
4937
|
|
|
4523
4938
|
/**
|