mail-time 4.1.0 → 5.0.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 +70 -18
- package/helpers.js +19 -0
- package/index.cjs +442 -119
- package/index.d.cts +50 -7
- package/index.d.ts +50 -7
- package/index.js +423 -118
- package/package.json +19 -4
- package/presets.js +2 -2
package/index.cjs
CHANGED
|
@@ -225,6 +225,24 @@ const filterAddressField = (field, acceptedSet) => {
|
|
|
225
225
|
return field;
|
|
226
226
|
};
|
|
227
227
|
|
|
228
|
+
const HTML_ESCAPES = {
|
|
229
|
+
'&': '&',
|
|
230
|
+
'<': '<',
|
|
231
|
+
'>': '>',
|
|
232
|
+
'"': '"',
|
|
233
|
+
"'": ''',
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @name escapeHtml - Escape the five HTML-significant characters.
|
|
238
|
+
* @function
|
|
239
|
+
* @param {string} value
|
|
240
|
+
* @returns {string} `value` safe to interpolate into HTML text or an attribute value.
|
|
241
|
+
*/
|
|
242
|
+
const escapeHtml = (value) => {
|
|
243
|
+
return `${value}`.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
|
|
244
|
+
};
|
|
245
|
+
|
|
228
246
|
/**
|
|
229
247
|
* @name isSendClaimUpdate - Detect an atomic send-claim update (`{ isSending: true, tries: N }`).
|
|
230
248
|
* @function
|
|
@@ -1855,7 +1873,7 @@ const PRESETS = Object.freeze({
|
|
|
1855
1873
|
retries: 5,
|
|
1856
1874
|
retryDelay: 2000,
|
|
1857
1875
|
revolvingInterval: 1024,
|
|
1858
|
-
sendingTimeout:
|
|
1876
|
+
sendingTimeout: 120_000,
|
|
1859
1877
|
mode: 'batch',
|
|
1860
1878
|
concurrency: 4,
|
|
1861
1879
|
onError: defaultPresetOnError,
|
|
@@ -1908,7 +1926,7 @@ const PRESETS = Object.freeze({
|
|
|
1908
1926
|
retries: 20,
|
|
1909
1927
|
retryDelay: 5000,
|
|
1910
1928
|
revolvingInterval: 1024,
|
|
1911
|
-
sendingTimeout:
|
|
1929
|
+
sendingTimeout: 120_000,
|
|
1912
1930
|
mode: 'batch',
|
|
1913
1931
|
concurrency: 2,
|
|
1914
1932
|
onError: defaultPresetOnError,
|
|
@@ -1975,6 +1993,51 @@ const mailTimePreset = (name, overrides) => {
|
|
|
1975
1993
|
const noop = () => {};
|
|
1976
1994
|
const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
|
|
1977
1995
|
|
|
1996
|
+
const callHook = (name, callback, ...args) => {
|
|
1997
|
+
try {
|
|
1998
|
+
const result = callback(...args);
|
|
1999
|
+
if (result && typeof result.then === 'function') {
|
|
2000
|
+
Promise.resolve(result).catch((hookError) => {
|
|
2001
|
+
logError(`[${name}] callback failed`, hookError);
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
} catch (hookError) {
|
|
2005
|
+
logError(`[${name}] callback failed`, hookError);
|
|
2006
|
+
}
|
|
2007
|
+
};
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Floor below which `sendingTimeout` is likely shorter than a real SMTP roundtrip
|
|
2011
|
+
* (connect + greeting + STARTTLS + envelope + DATA, times MX rollover). A claim that
|
|
2012
|
+
* expires mid-send is re-claimed by a peer, which delivers the same letter twice.
|
|
2013
|
+
*/
|
|
2014
|
+
const MIN_SAFE_SENDING_TIMEOUT = 120000;
|
|
2015
|
+
const DEFAULT_MAX_RENEWALS = 10;
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* Fields a queued task may set under `{strictPayload: true}`. Everything else —
|
|
2019
|
+
* `attachments`, `envelope`, `dkim`, `raw`, `icalEvent`, … — is dropped, because a
|
|
2020
|
+
* process with queue-write access would otherwise inherit nodemailer's whole
|
|
2021
|
+
* capability surface (local file read, URL fetch, sender/DKIM override).
|
|
2022
|
+
*/
|
|
2023
|
+
const STRICT_MAIL_FIELDS = [
|
|
2024
|
+
'to',
|
|
2025
|
+
'cc',
|
|
2026
|
+
'bcc',
|
|
2027
|
+
'replyTo',
|
|
2028
|
+
'subject',
|
|
2029
|
+
'text',
|
|
2030
|
+
'html',
|
|
2031
|
+
'headers',
|
|
2032
|
+
'list',
|
|
2033
|
+
'priority',
|
|
2034
|
+
'encoding',
|
|
2035
|
+
'textEncoding',
|
|
2036
|
+
'inReplyTo',
|
|
2037
|
+
'references',
|
|
2038
|
+
'date',
|
|
2039
|
+
];
|
|
2040
|
+
|
|
1978
2041
|
const __withLease = (task, updateObj) => {
|
|
1979
2042
|
if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
|
|
1980
2043
|
return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
|
|
@@ -2093,7 +2156,7 @@ const buildRejectionErrorMap = (info) => {
|
|
|
2093
2156
|
return map;
|
|
2094
2157
|
};
|
|
2095
2158
|
|
|
2096
|
-
let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml><meta content="text/html; charset=utf-8"http-equiv=Content-Type><meta content="width=device-width,initial-scale=1"name=viewport><title>{{subject}}</title><style>body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;font-family:Tiempos,Georgia,Times,serif;font-weight:400;width:100%;height:100%;background:#fff;font-size:15px;color:#000;line-height:1.5}a{text-decoration:underline;border:0;color:#000;outline:0;color:inherit}a:hover{text-decoration:none}a[href^=sms],a[href^=tel]{text-decoration:none;color:#000;cursor:default}a img{border:none;text-decoration:none}td{font-family:Tiempos,Georgia,Times,serif;font-weight:400}hr{height:1px;border:none;width:100%;margin:0;margin-top:25px;margin-bottom:25px;background-color:#ECECEC}h1,h2,h3,h4,h5,h6{font-family:HelveticaNeue,"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;line-height:normal;margin-top:35px;margin-bottom:4px;margin-left:0;margin-right:0}h1{margin:23px 15px;font-size:25px}h2{margin-top:15px;font-size:21px}h3{font-weight:400;font-size:19px;border-bottom:1px solid #ECECEC}h4{font-weight:400;font-size:18px}h5{font-weight:400;font-size:17px}h6{font-weight:600;font-size:16px}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{text-decoration:none}pre{font-family:Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;display:block;font-size:13px;padding:9.5px;margin:0 0 10px;line-height:1.42;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px;text-align:left!important;max-width:100%;white-space:pre-wrap;width:auto;overflow:auto}code{font-size:13px;font-family:font-family: Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;border:1px solid rgba(0,0,0,.223);border-radius:2px;padding:1px 2px;word-break:break-all;word-wrap:break-word}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border:none;border-radius:0;word-break:break-all;word-wrap:break-word}td{text-align:center}table{border-collapse:collapse!important}.force-full-width{width:100%!important}</style><style media=screen>@media screen{h1,h2,h3,h4,h5,h6{font-family:\'Helvetica Neue\',Arial,sans-serif!important}td{font-family:Tiempos,Georgia,Times,serif!important}code,pre{font-family:Consolas,Menlo,Monaco,\'Lucida Console\',\'Liberation Mono\',\'DejaVu Sans Mono\',\'Bitstream Vera Sans Mono\',\'Courier New\',monospace,sans-serif!important}}</style><style media="only screen and (max-width:480px)">@media only screen and (max-width:480px){table[class=w320]{width:100%!important}}</style><body bgcolor=#FFFFFF class=body style=padding:0;margin:0;display:block;background:#fff;-webkit-text-size-adjust:none><table cellpadding=0 cellspacing=0 width=100% align=center><tr><td align=center valign=top bgcolor=#FFFFFF width=100%><center><table cellpadding=0 cellspacing=0 width=600 style="margin:0 auto"class=w320><tr><td align=center valign=top><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto;border-bottom:1px solid #ddd"bgcolor=#ECECEC><tr><td><h1>{{
|
|
2159
|
+
let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml><meta content="text/html; charset=utf-8"http-equiv=Content-Type><meta content="width=device-width,initial-scale=1"name=viewport><title>{{subject}}</title><style>body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;font-family:Tiempos,Georgia,Times,serif;font-weight:400;width:100%;height:100%;background:#fff;font-size:15px;color:#000;line-height:1.5}a{text-decoration:underline;border:0;color:#000;outline:0;color:inherit}a:hover{text-decoration:none}a[href^=sms],a[href^=tel]{text-decoration:none;color:#000;cursor:default}a img{border:none;text-decoration:none}td{font-family:Tiempos,Georgia,Times,serif;font-weight:400}hr{height:1px;border:none;width:100%;margin:0;margin-top:25px;margin-bottom:25px;background-color:#ECECEC}h1,h2,h3,h4,h5,h6{font-family:HelveticaNeue,"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;line-height:normal;margin-top:35px;margin-bottom:4px;margin-left:0;margin-right:0}h1{margin:23px 15px;font-size:25px}h2{margin-top:15px;font-size:21px}h3{font-weight:400;font-size:19px;border-bottom:1px solid #ECECEC}h4{font-weight:400;font-size:18px}h5{font-weight:400;font-size:17px}h6{font-weight:600;font-size:16px}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{text-decoration:none}pre{font-family:Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;display:block;font-size:13px;padding:9.5px;margin:0 0 10px;line-height:1.42;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px;text-align:left!important;max-width:100%;white-space:pre-wrap;width:auto;overflow:auto}code{font-size:13px;font-family:font-family: Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;border:1px solid rgba(0,0,0,.223);border-radius:2px;padding:1px 2px;word-break:break-all;word-wrap:break-word}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border:none;border-radius:0;word-break:break-all;word-wrap:break-word}td{text-align:center}table{border-collapse:collapse!important}.force-full-width{width:100%!important}</style><style media=screen>@media screen{h1,h2,h3,h4,h5,h6{font-family:\'Helvetica Neue\',Arial,sans-serif!important}td{font-family:Tiempos,Georgia,Times,serif!important}code,pre{font-family:Consolas,Menlo,Monaco,\'Lucida Console\',\'Liberation Mono\',\'DejaVu Sans Mono\',\'Bitstream Vera Sans Mono\',\'Courier New\',monospace,sans-serif!important}}</style><style media="only screen and (max-width:480px)">@media only screen and (max-width:480px){table[class=w320]{width:100%!important}}</style><body bgcolor=#FFFFFF class=body style=padding:0;margin:0;display:block;background:#fff;-webkit-text-size-adjust:none><table cellpadding=0 cellspacing=0 width=100% align=center><tr><td align=center valign=top bgcolor=#FFFFFF width=100%><center><table cellpadding=0 cellspacing=0 width=600 style="margin:0 auto"class=w320><tr><td align=center valign=top><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto;border-bottom:1px solid #ddd"bgcolor=#ECECEC><tr><td><h1>{{subject}}</h1></table><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"bgcolor=#F2F2F2><tr><td><center><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"><tr><td align=left style="text-align:left;padding:30px 25px">{{{html}}}</table></center></table></table></center></table>';
|
|
2097
2160
|
|
|
2098
2161
|
/**
|
|
2099
2162
|
* @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
|
|
@@ -2124,7 +2187,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
2124
2187
|
*/
|
|
2125
2188
|
|
|
2126
2189
|
/**
|
|
2127
|
-
* @typedef {{ [key: string]: any, adapter: MailTimeJoSkAdapterOptions | object, debug?: boolean, autoClear?: boolean, zombieTime?: number, minRevolvingDelay?: number, maxRevolvingDelay?: number, execute?: 'batch' | 'one', concurrency?: number, lockOwnerId?: string, resetOnInit?: boolean, onError?: (title: string, details: object) => void, onExecuted?: (uid: string, details: object) => void }} MailTimeJoSkOptions
|
|
2190
|
+
* @typedef {{ [key: string]: any, adapter: MailTimeJoSkAdapterOptions | object, debug?: boolean, autoClear?: boolean, zombieTime?: number, lockLeaseTime?: number, minRevolvingDelay?: number, maxRevolvingDelay?: number, execute?: 'batch' | 'one', concurrency?: number, lockOwnerId?: string, resetOnInit?: boolean, onError?: (title: string, details: object) => void, onExecuted?: (uid: string, details: object) => void }} MailTimeJoSkOptions
|
|
2191
|
+
*/
|
|
2192
|
+
|
|
2193
|
+
/**
|
|
2194
|
+
* @typedef {{ [key: string]: any, ping: () => Promise<MailTimePingResult>, setInterval: (func: (...args: any[]) => unknown, delay: number, uid: string) => Promise<string>, destroy: () => boolean, pause: (timerId?: string) => boolean, resume: (timerId?: string) => boolean }} MailTimeScheduler
|
|
2128
2195
|
*/
|
|
2129
2196
|
|
|
2130
2197
|
/**
|
|
@@ -2152,7 +2219,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
2152
2219
|
*/
|
|
2153
2220
|
|
|
2154
2221
|
/**
|
|
2155
|
-
* @typedef {{
|
|
2222
|
+
* @typedef {{ index: number, from: string | undefined }} MailTimeFromDetails
|
|
2223
|
+
*/
|
|
2224
|
+
|
|
2225
|
+
/**
|
|
2226
|
+
* @typedef {{ queue: RedisQueue | MongoQueue | PostgresQueue | CustomQueue, type?: 'server' | 'client', from?: string | ((transport: MailTimeTransport, details: MailTimeFromDetails) => string), transports?: MailTimeTransport[], strategy?: 'backup' | 'balancer', failsToNext?: number, shouldFailOver?: (error: unknown, info: object | undefined, email: MailTimeTask) => boolean, retries?: number, maxTries?: number, retryDelay?: number, interval?: number, keepHistory?: boolean, concatEmails?: boolean | MailTimeConcatEmailsOptions, concatSubject?: string, concatDelimiter?: string, concatDelay?: number, concatThrottling?: number, revolvingInterval?: number, mode?: 'one' | 'batch', concurrency?: number, sendingTimeout?: number, renewClaim?: boolean | number, maxRenewals?: number, strictPayload?: boolean, allowedMailFields?: string[], verifyTransports?: boolean, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, onError?: (error: unknown, email: MailTimeTask | null, details?: object) => void, onSent?: (email: MailTimeTask, details?: object) => void }} MailTimeOptions
|
|
2156
2227
|
*/
|
|
2157
2228
|
|
|
2158
2229
|
/** Class of MailTime */
|
|
@@ -2213,7 +2284,34 @@ class MailTime {
|
|
|
2213
2284
|
this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
|
|
2214
2285
|
this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
|
|
2215
2286
|
this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
|
|
2216
|
-
this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
|
|
2287
|
+
this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && Number.isFinite(opts.sendingTimeout) && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
|
|
2288
|
+
if (this.sendingTimeout < MIN_SAFE_SENDING_TIMEOUT) {
|
|
2289
|
+
logError(`{sendingTimeout: ${this.sendingTimeout}} is below the ${MIN_SAFE_SENDING_TIMEOUT}ms safe floor — a claim that expires while SMTP is still in flight is re-claimed by a peer and the letter is delivered twice. Raise {sendingTimeout} above the worst-case SMTP roundtrip.`);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// Claim renewal: MailTime has no way to know how long a transport will take, so a
|
|
2293
|
+
// long-but-healthy send used to lose its lock to a "recovery" worker. While a send
|
|
2294
|
+
// is in flight the claim is re-stamped every {renewClaim} ms — bounded by
|
|
2295
|
+
// {maxRenewals} so a genuinely wedged send is still recovered, just later.
|
|
2296
|
+
if (opts.renewClaim === false || opts.renewClaim === 0) {
|
|
2297
|
+
this.renewClaim = 0;
|
|
2298
|
+
} else if (typeof opts.renewClaim === 'number' && Number.isFinite(opts.renewClaim) && opts.renewClaim > 0) {
|
|
2299
|
+
this.renewClaim = Math.floor(opts.renewClaim);
|
|
2300
|
+
} else {
|
|
2301
|
+
this.renewClaim = Math.max(1000, Math.floor(this.sendingTimeout / 3));
|
|
2302
|
+
}
|
|
2303
|
+
this.maxRenewals = (typeof opts.maxRenewals === 'number' && Number.isFinite(opts.maxRenewals) && opts.maxRenewals >= 0) ? Math.floor(opts.maxRenewals) : DEFAULT_MAX_RENEWALS;
|
|
2304
|
+
|
|
2305
|
+
// Transport fail-over policy. Rotating on *any* error can resubmit a letter the
|
|
2306
|
+
// MX already accepted; a transport that knows better says so.
|
|
2307
|
+
this.shouldFailOver = (typeof opts.shouldFailOver === 'function') ? opts.shouldFailOver.bind(this) : null;
|
|
2308
|
+
|
|
2309
|
+
this.strictPayload = opts.strictPayload === true;
|
|
2310
|
+
this.allowedMailFields = new Set([
|
|
2311
|
+
...STRICT_MAIL_FIELDS,
|
|
2312
|
+
...(Array.isArray(opts.allowedMailFields) ? opts.allowedMailFields.filter((field) => typeof field === 'string') : []),
|
|
2313
|
+
]);
|
|
2314
|
+
|
|
2217
2315
|
this.__isDestroyed = false;
|
|
2218
2316
|
this.__abortInFlight = false;
|
|
2219
2317
|
this.__isPaused = false;
|
|
@@ -2272,6 +2370,8 @@ class MailTime {
|
|
|
2272
2370
|
this.__debug(`INITIALIZING [mode: ${this.mode}]`);
|
|
2273
2371
|
this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
|
|
2274
2372
|
this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
|
|
2373
|
+
this.__debug(`INITIALIZING [renewClaim: ${this.renewClaim}]`);
|
|
2374
|
+
this.__debug(`INITIALIZING [strictPayload: ${this.strictPayload}]`);
|
|
2275
2375
|
|
|
2276
2376
|
/** SERVER-SPECIFIC CHECKS AND CONFIG */
|
|
2277
2377
|
if (this.type === 'server') {
|
|
@@ -2336,6 +2436,7 @@ class MailTime {
|
|
|
2336
2436
|
};
|
|
2337
2437
|
}
|
|
2338
2438
|
|
|
2439
|
+
/** @type {MailTimeScheduler | undefined} */
|
|
2339
2440
|
this.scheduler = new josk.JoSk({
|
|
2340
2441
|
debug: this.debug,
|
|
2341
2442
|
...this.josk,
|
|
@@ -2501,12 +2602,17 @@ class MailTime {
|
|
|
2501
2602
|
* @returns {Promise<string>} uuid of the email
|
|
2502
2603
|
* @throws {Error}
|
|
2503
2604
|
*/
|
|
2504
|
-
async sendMail(opts
|
|
2605
|
+
async sendMail(opts) {
|
|
2606
|
+
opts = (opts && typeof opts === 'object') ? opts : {};
|
|
2505
2607
|
this.__debug('[sendMail]', opts);
|
|
2506
2608
|
if (!opts.html && !opts.text) {
|
|
2507
2609
|
throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
|
|
2508
2610
|
}
|
|
2509
2611
|
|
|
2612
|
+
if (opts.raw !== void 0) {
|
|
2613
|
+
throw new Error('[mail-time] [sendMail] `raw` is not supported: it bypasses composition, so `template`, `concatEmails`, and the `from()` callback would silently stop applying — and nodemailer historically let a message-level `raw` bypass `disableFileAccess`/`disableUrlAccess`. Pass `html` / `text` instead.');
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2510
2616
|
let sendAt = opts.sendAt;
|
|
2511
2617
|
if (sendAt instanceof Date) {
|
|
2512
2618
|
sendAt = +sendAt;
|
|
@@ -2635,13 +2741,16 @@ class MailTime {
|
|
|
2635
2741
|
task.isSending = false;
|
|
2636
2742
|
|
|
2637
2743
|
this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
|
|
2638
|
-
this.onError
|
|
2744
|
+
callHook('onError', this.onError, error, task, info);
|
|
2639
2745
|
return;
|
|
2640
2746
|
}
|
|
2641
2747
|
|
|
2642
2748
|
let transportIndex = task.transport;
|
|
2643
2749
|
|
|
2644
|
-
if (this.strategy === 'backup'
|
|
2750
|
+
if (this.strategy === 'backup'
|
|
2751
|
+
&& this.transports.length > 1
|
|
2752
|
+
&& (task.tries % this.failsToNext) === 0
|
|
2753
|
+
&& this.___mayFailOver(error, info, task)) {
|
|
2645
2754
|
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
2646
2755
|
}
|
|
2647
2756
|
|
|
@@ -2703,12 +2812,18 @@ class MailTime {
|
|
|
2703
2812
|
* @internal
|
|
2704
2813
|
* @memberOf MailTime
|
|
2705
2814
|
* @name ___render
|
|
2706
|
-
* @description Render Mustache-like placeholders
|
|
2815
|
+
* @description Render Mustache-like placeholders. `{{{key}}}` is always verbatim.
|
|
2816
|
+
* `{{key}}` is HTML-escaped in HTML contexts (`html`, `template`, `concatDelimiter`) —
|
|
2817
|
+
* that is what double-brace means everywhere else, and the previous strip-tags pass was
|
|
2818
|
+
* not a safety net: it needs a closing `>`, so an unterminated `<a href="…` survived
|
|
2819
|
+
* into the rendered body. Text bodies and subject headers are not HTML, so `{{key}}`
|
|
2820
|
+
* substitutes verbatim there.
|
|
2707
2821
|
* @param {string} _string - Template with Mustache-like placeholders
|
|
2708
2822
|
* @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
|
|
2823
|
+
* @param {boolean} [isHtml=true] - `false` for text/plain bodies and header values
|
|
2709
2824
|
* @returns {string}
|
|
2710
2825
|
*/
|
|
2711
|
-
___render(_string, replacements) {
|
|
2826
|
+
___render(_string, replacements, isHtml = true) {
|
|
2712
2827
|
let string = _string;
|
|
2713
2828
|
const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
|
|
2714
2829
|
if (matchHTML) {
|
|
@@ -2725,7 +2840,8 @@ class MailTime {
|
|
|
2725
2840
|
for (let i = 0; i < matchStr.length; i++) {
|
|
2726
2841
|
const key = matchStr[i].slice(2, -2).trim();
|
|
2727
2842
|
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
2728
|
-
|
|
2843
|
+
const value = `${replacements[key]}`;
|
|
2844
|
+
string = string.replace(matchStr[i], isHtml ? escapeHtml(value) : value);
|
|
2729
2845
|
}
|
|
2730
2846
|
}
|
|
2731
2847
|
}
|
|
@@ -2764,7 +2880,7 @@ class MailTime {
|
|
|
2764
2880
|
const isMulti = mailOptionsList.length > 1;
|
|
2765
2881
|
|
|
2766
2882
|
for (let i = 0; i < mailOptionsList.length; i++) {
|
|
2767
|
-
const mailOption = { ...mailOptionsList[i] };
|
|
2883
|
+
const mailOption = this.___applyPayloadPolicy({ ...mailOptionsList[i] });
|
|
2768
2884
|
|
|
2769
2885
|
if (mailOption.html) {
|
|
2770
2886
|
const rendered = this.___render(mailOption.html, mailOption);
|
|
@@ -2777,7 +2893,7 @@ class MailTime {
|
|
|
2777
2893
|
}
|
|
2778
2894
|
|
|
2779
2895
|
if (mailOption.text) {
|
|
2780
|
-
const rendered = this.___render(mailOption.text, mailOption);
|
|
2896
|
+
const rendered = this.___render(mailOption.text, mailOption, false);
|
|
2781
2897
|
if (isMulti) {
|
|
2782
2898
|
compiledOpts.text += '\r\n' + rendered;
|
|
2783
2899
|
} else {
|
|
@@ -2795,11 +2911,14 @@ class MailTime {
|
|
|
2795
2911
|
|
|
2796
2912
|
if (isMulti) {
|
|
2797
2913
|
const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
|
|
2798
|
-
compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
|
|
2914
|
+
compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length }, false);
|
|
2799
2915
|
}
|
|
2800
2916
|
|
|
2801
2917
|
if (!compiledOpts.from && this.from) {
|
|
2802
|
-
compiledOpts.from = this.from(transport
|
|
2918
|
+
compiledOpts.from = this.from(transport, {
|
|
2919
|
+
index: this.transports.indexOf(transport),
|
|
2920
|
+
from: MailTime.transportFrom(transport),
|
|
2921
|
+
});
|
|
2803
2922
|
}
|
|
2804
2923
|
|
|
2805
2924
|
const acceptedSet = collectAcceptedSet(task);
|
|
@@ -2809,9 +2928,84 @@ class MailTime {
|
|
|
2809
2928
|
compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
|
|
2810
2929
|
}
|
|
2811
2930
|
|
|
2931
|
+
if (this.strictPayload) {
|
|
2932
|
+
compiledOpts.disableFileAccess = true;
|
|
2933
|
+
compiledOpts.disableUrlAccess = true;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2812
2936
|
return compiledOpts;
|
|
2813
2937
|
}
|
|
2814
2938
|
|
|
2939
|
+
/**
|
|
2940
|
+
* @internal
|
|
2941
|
+
* @memberOf MailTime
|
|
2942
|
+
* @name ___applyPayloadPolicy
|
|
2943
|
+
* @description Narrow one queued mail option to what a task is allowed to set. `raw` is
|
|
2944
|
+
* always dropped: it bypasses composition (so `template`, `concatEmails` and the
|
|
2945
|
+
* `from()` callback silently stop applying) and historically bypassed nodemailer's
|
|
2946
|
+
* `disableFileAccess`/`disableUrlAccess` with it. Under `{strictPayload: true}` only
|
|
2947
|
+
* `allowedMailFields` survive.
|
|
2948
|
+
* @param {MailTimeMailOptions} mailOption - a single queued mail option object
|
|
2949
|
+
* @returns {MailTimeMailOptions}
|
|
2950
|
+
*/
|
|
2951
|
+
___applyPayloadPolicy(mailOption) {
|
|
2952
|
+
if (mailOption.raw !== void 0) {
|
|
2953
|
+
delete mailOption.raw;
|
|
2954
|
+
logError('[___compileMailOpts] dropped {raw} from a queued task — `raw` bypasses composition and is never sent by MailTime');
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
if (!this.strictPayload) {
|
|
2958
|
+
return mailOption;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
const allowed = {};
|
|
2962
|
+
for (const key of Object.keys(mailOption)) {
|
|
2963
|
+
if (this.allowedMailFields.has(key)) {
|
|
2964
|
+
allowed[key] = mailOption[key];
|
|
2965
|
+
} else {
|
|
2966
|
+
this.__debug('[___applyPayloadPolicy] dropped non-allowlisted field', key);
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
return allowed;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
/**
|
|
2973
|
+
* @static
|
|
2974
|
+
* @memberOf MailTime
|
|
2975
|
+
* @name transportFrom
|
|
2976
|
+
* @description Best-effort sender address for a transport. `nodemailer.createTransport()`
|
|
2977
|
+
* only exposes `.options` for *plain-object* configs — for a class-instance transporter
|
|
2978
|
+
* (anything with its own `.send()`) nodemailer's internal `Mail.options` is always `{}`,
|
|
2979
|
+
* so `transport.options.from` silently reads `undefined`. This walks the places the
|
|
2980
|
+
* address can actually live.
|
|
2981
|
+
* @param {MailTimeTransport} transport
|
|
2982
|
+
* @returns {string | undefined}
|
|
2983
|
+
*/
|
|
2984
|
+
static transportFrom(transport) {
|
|
2985
|
+
const candidates = [
|
|
2986
|
+
transport?.options?.from,
|
|
2987
|
+
transport?.transporter?.options?.from,
|
|
2988
|
+
transport?._defaults?.from,
|
|
2989
|
+
transport?._options?.from,
|
|
2990
|
+
];
|
|
2991
|
+
|
|
2992
|
+
for (const candidate of candidates) {
|
|
2993
|
+
// nodemailer accepts `from` as a string or as `{ name, address }`. Resolve the
|
|
2994
|
+
// first candidate that yields a usable address — not the first truthy slot, so a
|
|
2995
|
+
// malformed `{ name }` object cannot mask a real address further down the chain.
|
|
2996
|
+
const address = (typeof candidate === 'string')
|
|
2997
|
+
? candidate
|
|
2998
|
+
: (candidate && typeof candidate === 'object' && typeof candidate.address === 'string')
|
|
2999
|
+
? candidate.address
|
|
3000
|
+
: '';
|
|
3001
|
+
if (address.trim()) {
|
|
3002
|
+
return address;
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
return void 0;
|
|
3007
|
+
}
|
|
3008
|
+
|
|
2815
3009
|
/**
|
|
2816
3010
|
* @internal
|
|
2817
3011
|
* @memberOf MailTime
|
|
@@ -2876,6 +3070,124 @@ class MailTime {
|
|
|
2876
3070
|
}
|
|
2877
3071
|
}
|
|
2878
3072
|
|
|
3073
|
+
/**
|
|
3074
|
+
* @internal
|
|
3075
|
+
* @memberOf MailTime
|
|
3076
|
+
* @name ___mayFailOver
|
|
3077
|
+
* @description Whether this failure may be retried on the *next* transport. Rotating on
|
|
3078
|
+
* any error can resubmit a letter the receiving MTA already accepted (or may have
|
|
3079
|
+
* accepted — a socket that died during `DATA` is indistinguishable from one that died
|
|
3080
|
+
* before it). A transport signals "do not fail over" with `error.mayFailOver = false`;
|
|
3081
|
+
* the `shouldFailOver(error, info, task)` option overrides both.
|
|
3082
|
+
* @param {unknown} error - error returned by the transport
|
|
3083
|
+
* @param {object|undefined} info - info object returned by the transport
|
|
3084
|
+
* @param {MailTimeTask} task - email's task object from Storage
|
|
3085
|
+
* @returns {boolean}
|
|
3086
|
+
*/
|
|
3087
|
+
___mayFailOver(error, info, task) {
|
|
3088
|
+
if (this.shouldFailOver) {
|
|
3089
|
+
try {
|
|
3090
|
+
return this.shouldFailOver(error, info, task) !== false;
|
|
3091
|
+
} catch (policyError) {
|
|
3092
|
+
logError('[shouldFailOver] callback failed; keeping the current transport', policyError);
|
|
3093
|
+
return false;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
return !(error && typeof error === 'object' && error.mayFailOver === false);
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
/**
|
|
3100
|
+
* @internal
|
|
3101
|
+
* @memberOf MailTime
|
|
3102
|
+
* @name ___startClaimRenewal
|
|
3103
|
+
* @description Hold a claim alive while its SMTP roundtrip runs. MailTime 4.x stamped
|
|
3104
|
+
* `sendingAt` once at claim time, so any send slower than `sendingTimeout` lost its lock
|
|
3105
|
+
* to a "recovery" worker and the letter went out twice. The renewal is a lease-guarded
|
|
3106
|
+
* update — it succeeds only while *this* worker still owns the row — and is bounded by
|
|
3107
|
+
* `maxRenewals` so a genuinely wedged send is still recovered.
|
|
3108
|
+
* @param {MailTimeTask} task - claimed task, with `isSending`/`sendingAt`/`tries` set
|
|
3109
|
+
* @returns {{ stop: () => Promise<void> }}
|
|
3110
|
+
*/
|
|
3111
|
+
___startClaimRenewal(task) {
|
|
3112
|
+
if (!this.renewClaim || !this.maxRenewals || this.type !== 'server') {
|
|
3113
|
+
return { stop: async () => {} };
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
let stopped = false;
|
|
3117
|
+
let renewals = 0;
|
|
3118
|
+
let timer = null;
|
|
3119
|
+
let activeRenewal = null;
|
|
3120
|
+
const halt = () => {
|
|
3121
|
+
if (stopped) {
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
stopped = true;
|
|
3125
|
+
if (timer) {
|
|
3126
|
+
clearInterval(timer);
|
|
3127
|
+
timer = null;
|
|
3128
|
+
}
|
|
3129
|
+
};
|
|
3130
|
+
const stop = async () => {
|
|
3131
|
+
halt();
|
|
3132
|
+
if (activeRenewal) {
|
|
3133
|
+
await activeRenewal;
|
|
3134
|
+
}
|
|
3135
|
+
};
|
|
3136
|
+
|
|
3137
|
+
const renew = async () => {
|
|
3138
|
+
if (stopped || this.__isDestroyed || this.__abortInFlight) {
|
|
3139
|
+
halt();
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
if (renewals >= this.maxRenewals) {
|
|
3144
|
+
this.__debug('[private renewClaim] renewal budget exhausted; letting the claim go stale', task.uuid);
|
|
3145
|
+
halt();
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
renewals += 1;
|
|
3149
|
+
|
|
3150
|
+
// Strictly greater than the current stamp: storages that report "modified" only on
|
|
3151
|
+
// an actual value change would otherwise read a same-millisecond renewal as a no-op.
|
|
3152
|
+
const renewedAt = Math.max(Date.now(), (typeof task.sendingAt === 'number' ? task.sendingAt : 0) + 1);
|
|
3153
|
+
let renewed = false;
|
|
3154
|
+
try {
|
|
3155
|
+
renewed = await this.queue.update(task, __withLease(task, {
|
|
3156
|
+
isSending: true,
|
|
3157
|
+
sendingAt: renewedAt,
|
|
3158
|
+
}));
|
|
3159
|
+
} catch (renewError) {
|
|
3160
|
+
logError('[private renewClaim] storage error during claim renewal', renewError);
|
|
3161
|
+
halt();
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
if (!renewed) {
|
|
3166
|
+
this.__debug('[private renewClaim] lease lost; stopping renewal', task.uuid);
|
|
3167
|
+
halt();
|
|
3168
|
+
return;
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
task.sendingAt = renewedAt;
|
|
3172
|
+
this.__debug(`[private renewClaim] renewed ${renewals}/${this.maxRenewals}`, task.uuid);
|
|
3173
|
+
};
|
|
3174
|
+
|
|
3175
|
+
timer = setInterval(() => {
|
|
3176
|
+
if (activeRenewal) {
|
|
3177
|
+
return;
|
|
3178
|
+
}
|
|
3179
|
+
activeRenewal = renew().finally(() => {
|
|
3180
|
+
activeRenewal = null;
|
|
3181
|
+
});
|
|
3182
|
+
}, this.renewClaim);
|
|
3183
|
+
|
|
3184
|
+
if (typeof timer.unref === 'function') {
|
|
3185
|
+
timer.unref();
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
return { stop };
|
|
3189
|
+
}
|
|
3190
|
+
|
|
2879
3191
|
/**
|
|
2880
3192
|
* @async
|
|
2881
3193
|
* @internal
|
|
@@ -2956,131 +3268,142 @@ class MailTime {
|
|
|
2956
3268
|
const transport = this.transports[transportIndex];
|
|
2957
3269
|
|
|
2958
3270
|
const compiledOpts = this.___compileMailOpts(transport, task);
|
|
3271
|
+
const renewal = this.___startClaimRenewal(task);
|
|
2959
3272
|
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
if (error) {
|
|
2970
|
-
await this.___handleError(task, error, info);
|
|
3273
|
+
try {
|
|
3274
|
+
await new Promise((resolve) => {
|
|
3275
|
+
transport.sendMail(compiledOpts, async (error, info) => {
|
|
3276
|
+
// The roundtrip has settled — stop extending the lease before any completion
|
|
3277
|
+
// write, so the guard below sees the last `sendingAt` this worker persisted.
|
|
3278
|
+
await renewal.stop();
|
|
3279
|
+
if (this.__abortInFlight) {
|
|
3280
|
+
this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
|
|
3281
|
+
resolve();
|
|
2971
3282
|
return;
|
|
2972
3283
|
}
|
|
3284
|
+
this.__debug('[private send] [sending]', { error, info });
|
|
3285
|
+
try {
|
|
3286
|
+
if (error) {
|
|
3287
|
+
await this.___handleError(task, error, info);
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
2973
3290
|
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
3291
|
+
const acceptedAddrs = Array.isArray(info?.accepted)
|
|
3292
|
+
? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
|
|
3293
|
+
: [];
|
|
2977
3294
|
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
3295
|
+
if (acceptedAddrs.length === 0) {
|
|
3296
|
+
await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
2982
3299
|
|
|
2983
|
-
|
|
3300
|
+
this.___trackAcceptedRecipients(task, acceptedAddrs);
|
|
2984
3301
|
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
3302
|
+
const allRecipients = collectAllRecipients(task);
|
|
3303
|
+
const allAccepted = collectAcceptedSet(task);
|
|
3304
|
+
let isFullyDelivered = true;
|
|
3305
|
+
for (const addr of allRecipients) {
|
|
3306
|
+
if (!allAccepted.has(addr)) {
|
|
3307
|
+
isFullyDelivered = false;
|
|
3308
|
+
break;
|
|
3309
|
+
}
|
|
2992
3310
|
}
|
|
2993
|
-
}
|
|
2994
|
-
|
|
2995
|
-
if (isFullyDelivered) {
|
|
2996
|
-
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
2997
3311
|
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3312
|
+
if (isFullyDelivered) {
|
|
3313
|
+
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
3314
|
+
|
|
3315
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
3316
|
+
const leaseUpdate = __withLease(task, {
|
|
3317
|
+
isSent: true,
|
|
3318
|
+
isSending: false,
|
|
3319
|
+
sendingAt: 0,
|
|
3320
|
+
mailOptions: task.mailOptions,
|
|
3321
|
+
});
|
|
3322
|
+
|
|
3323
|
+
let completed = false;
|
|
3324
|
+
if (!this.keepHistory) {
|
|
3325
|
+
completed = await this.queue.remove(task, leaseRemove);
|
|
3326
|
+
} else {
|
|
3327
|
+
completed = await this.queue.update(task, leaseUpdate);
|
|
3328
|
+
}
|
|
3005
3329
|
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
completed = await this.queue.update(task, leaseUpdate);
|
|
3011
|
-
}
|
|
3330
|
+
if (!completed) {
|
|
3331
|
+
this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
3012
3334
|
|
|
3013
|
-
|
|
3014
|
-
|
|
3335
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
|
|
3336
|
+
task.isSent = true;
|
|
3337
|
+
task.isSending = false;
|
|
3338
|
+
task.sendingAt = 0;
|
|
3339
|
+
callHook('onSent', this.onSent, task, info);
|
|
3015
3340
|
return;
|
|
3016
3341
|
}
|
|
3017
3342
|
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3343
|
+
if (task.tries >= this.maxTries) {
|
|
3344
|
+
this.___finalizeRejected(task, info);
|
|
3345
|
+
|
|
3346
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
3347
|
+
const leaseUpdate = __withLease(task, {
|
|
3348
|
+
isSent: false,
|
|
3349
|
+
isFailed: true,
|
|
3350
|
+
isSending: false,
|
|
3351
|
+
sendingAt: 0,
|
|
3352
|
+
mailOptions: task.mailOptions,
|
|
3353
|
+
});
|
|
3354
|
+
|
|
3355
|
+
let finalized = false;
|
|
3356
|
+
if (!this.keepHistory) {
|
|
3357
|
+
finalized = await this.queue.remove(task, leaseRemove);
|
|
3358
|
+
} else {
|
|
3359
|
+
finalized = await this.queue.update(task, leaseUpdate);
|
|
3360
|
+
}
|
|
3028
3361
|
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
isSending: false,
|
|
3034
|
-
sendingAt: 0,
|
|
3035
|
-
mailOptions: task.mailOptions,
|
|
3036
|
-
});
|
|
3362
|
+
if (!finalized) {
|
|
3363
|
+
this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
|
|
3364
|
+
return;
|
|
3365
|
+
}
|
|
3037
3366
|
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
3043
|
-
}
|
|
3367
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
|
|
3368
|
+
task.isSent = false;
|
|
3369
|
+
task.isFailed = true;
|
|
3370
|
+
task.isSending = false;
|
|
3044
3371
|
|
|
3045
|
-
|
|
3046
|
-
|
|
3372
|
+
const rejectedAddrs = [];
|
|
3373
|
+
for (const mo of task.mailOptions) {
|
|
3374
|
+
for (const r of (mo.rejected || [])) {
|
|
3375
|
+
rejectedAddrs.push(r.address);
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
|
|
3379
|
+
this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
|
|
3380
|
+
callHook('onError', this.onError, partialError, task, info);
|
|
3047
3381
|
return;
|
|
3048
3382
|
}
|
|
3049
3383
|
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3384
|
+
const nextSendAt = Date.now() + this.retryDelay;
|
|
3385
|
+
const released = await this.queue.update(task, __withLease(task, {
|
|
3386
|
+
isSending: false,
|
|
3387
|
+
sendingAt: 0,
|
|
3388
|
+
sendAt: nextSendAt,
|
|
3389
|
+
mailOptions: task.mailOptions,
|
|
3390
|
+
}));
|
|
3391
|
+
if (!released) {
|
|
3392
|
+
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
3393
|
+
return;
|
|
3060
3394
|
}
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
const nextSendAt = Date.now() + this.retryDelay;
|
|
3068
|
-
const released = await this.queue.update(task, __withLease(task, {
|
|
3069
|
-
isSending: false,
|
|
3070
|
-
sendingAt: 0,
|
|
3071
|
-
sendAt: nextSendAt,
|
|
3072
|
-
mailOptions: task.mailOptions,
|
|
3073
|
-
}));
|
|
3074
|
-
if (!released) {
|
|
3075
|
-
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
3076
|
-
return;
|
|
3395
|
+
this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
|
|
3396
|
+
} catch (completionError) {
|
|
3397
|
+
logError('[private send] completion error after transport callback', completionError);
|
|
3398
|
+
} finally {
|
|
3399
|
+
resolve();
|
|
3077
3400
|
}
|
|
3078
|
-
|
|
3079
|
-
} finally {
|
|
3080
|
-
resolve();
|
|
3081
|
-
}
|
|
3401
|
+
});
|
|
3082
3402
|
});
|
|
3083
|
-
}
|
|
3403
|
+
} finally {
|
|
3404
|
+
// A transport that throws synchronously never reaches the callback above.
|
|
3405
|
+
await renewal.stop();
|
|
3406
|
+
}
|
|
3084
3407
|
} catch (e) {
|
|
3085
3408
|
if (this.__abortInFlight) {
|
|
3086
3409
|
this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
|
|
@@ -3167,7 +3490,7 @@ class MailTime {
|
|
|
3167
3490
|
}
|
|
3168
3491
|
this.__unhealthyTransports.add(r.index);
|
|
3169
3492
|
logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
|
|
3170
|
-
this.onError
|
|
3493
|
+
callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
|
|
3171
3494
|
}
|
|
3172
3495
|
|
|
3173
3496
|
if (this.__unhealthyTransports.size === this.transports.length) {
|