mail-time 4.0.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 +145 -35
- package/adapters/mongo.js +6 -3
- package/adapters/postgres.js +19 -3
- package/helpers.js +105 -9
- package/index.cjs +629 -141
- package/index.d.cts +74 -9
- package/index.d.ts +74 -9
- package/index.js +497 -121
- package/package.json +32 -5
- package/presets.d.ts +4 -4
- package/presets.js +6 -6
package/index.js
CHANGED
|
@@ -1,15 +1,60 @@
|
|
|
1
1
|
import { JoSk, RedisAdapter, MongoAdapter, PostgresAdapter } from 'josk';
|
|
2
|
-
import { randomUUID } from '
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
3
|
|
|
4
4
|
import { MongoQueue } from './adapters/mongo.js';
|
|
5
5
|
import { RedisQueue } from './adapters/redis.js';
|
|
6
6
|
import { PostgresQueue } from './adapters/postgres.js';
|
|
7
7
|
import { mailTimePreset, presets, presetNames } from './presets.js';
|
|
8
|
-
import { debug, logError, deepMerge, equals, isPlainObject, extractEmail, toAddressList, filterAddressField } from './helpers.js';
|
|
8
|
+
import { debug, escapeHtml, logError, hasOwnProp, deepMerge, equals, isPlainObject, extractEmail, toAddressList, filterAddressField } from './helpers.js';
|
|
9
9
|
|
|
10
10
|
const noop = () => {};
|
|
11
11
|
const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
|
|
12
12
|
|
|
13
|
+
const callHook = (name, callback, ...args) => {
|
|
14
|
+
try {
|
|
15
|
+
const result = callback(...args);
|
|
16
|
+
if (result && typeof result.then === 'function') {
|
|
17
|
+
Promise.resolve(result).catch((hookError) => {
|
|
18
|
+
logError(`[${name}] callback failed`, hookError);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
} catch (hookError) {
|
|
22
|
+
logError(`[${name}] callback failed`, hookError);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Floor below which `sendingTimeout` is likely shorter than a real SMTP roundtrip
|
|
28
|
+
* (connect + greeting + STARTTLS + envelope + DATA, times MX rollover). A claim that
|
|
29
|
+
* expires mid-send is re-claimed by a peer, which delivers the same letter twice.
|
|
30
|
+
*/
|
|
31
|
+
const MIN_SAFE_SENDING_TIMEOUT = 120000;
|
|
32
|
+
const DEFAULT_MAX_RENEWALS = 10;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fields a queued task may set under `{strictPayload: true}`. Everything else —
|
|
36
|
+
* `attachments`, `envelope`, `dkim`, `raw`, `icalEvent`, … — is dropped, because a
|
|
37
|
+
* process with queue-write access would otherwise inherit nodemailer's whole
|
|
38
|
+
* capability surface (local file read, URL fetch, sender/DKIM override).
|
|
39
|
+
*/
|
|
40
|
+
const STRICT_MAIL_FIELDS = [
|
|
41
|
+
'to',
|
|
42
|
+
'cc',
|
|
43
|
+
'bcc',
|
|
44
|
+
'replyTo',
|
|
45
|
+
'subject',
|
|
46
|
+
'text',
|
|
47
|
+
'html',
|
|
48
|
+
'headers',
|
|
49
|
+
'list',
|
|
50
|
+
'priority',
|
|
51
|
+
'encoding',
|
|
52
|
+
'textEncoding',
|
|
53
|
+
'inReplyTo',
|
|
54
|
+
'references',
|
|
55
|
+
'date',
|
|
56
|
+
];
|
|
57
|
+
|
|
13
58
|
const __withLease = (task, updateObj) => {
|
|
14
59
|
if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
|
|
15
60
|
return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
|
|
@@ -128,7 +173,7 @@ const buildRejectionErrorMap = (info) => {
|
|
|
128
173
|
return map;
|
|
129
174
|
};
|
|
130
175
|
|
|
131
|
-
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>{{
|
|
176
|
+
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>';
|
|
132
177
|
|
|
133
178
|
/**
|
|
134
179
|
* @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
|
|
@@ -139,7 +184,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
139
184
|
*/
|
|
140
185
|
|
|
141
186
|
/**
|
|
142
|
-
* @typedef {{ status: string, code: number, statusCode: number, error?: unknown }} MailTimePingResult
|
|
187
|
+
* @typedef {{ status: string, code: number, statusCode: number, paused?: boolean, error?: unknown }} MailTimePingResult
|
|
143
188
|
*/
|
|
144
189
|
|
|
145
190
|
/**
|
|
@@ -159,7 +204,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
159
204
|
*/
|
|
160
205
|
|
|
161
206
|
/**
|
|
162
|
-
* @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
|
|
207
|
+
* @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
|
|
208
|
+
*/
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @typedef {{ [key: string]: any, ping: () => Promise<MailTimePingResult>, setInterval: (func: (...args: any[]) => unknown, delay: number, uid: string) => Promise<string>, destroy: () => boolean, pause: (timerId?: string) => boolean, resume: (timerId?: string) => boolean }} MailTimeScheduler
|
|
163
212
|
*/
|
|
164
213
|
|
|
165
214
|
/**
|
|
@@ -187,7 +236,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
187
236
|
*/
|
|
188
237
|
|
|
189
238
|
/**
|
|
190
|
-
* @typedef {{
|
|
239
|
+
* @typedef {{ index: number, from: string | undefined }} MailTimeFromDetails
|
|
240
|
+
*/
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @typedef {{ queue: RedisQueue | MongoQueue | PostgresQueue | CustomQueue, type?: 'server' | 'client', from?: string | ((transport: MailTimeTransport, details: MailTimeFromDetails) => string), transports?: MailTimeTransport[], strategy?: 'backup' | 'balancer', failsToNext?: number, shouldFailOver?: (error: unknown, info: object | undefined, email: MailTimeTask) => boolean, retries?: number, maxTries?: number, retryDelay?: number, interval?: number, keepHistory?: boolean, concatEmails?: boolean | MailTimeConcatEmailsOptions, concatSubject?: string, concatDelimiter?: string, concatDelay?: number, concatThrottling?: number, revolvingInterval?: number, mode?: 'one' | 'batch', concurrency?: number, sendingTimeout?: number, renewClaim?: boolean | number, maxRenewals?: number, strictPayload?: boolean, allowedMailFields?: string[], verifyTransports?: boolean, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, onError?: (error: unknown, email: MailTimeTask | null, details?: object) => void, onSent?: (email: MailTimeTask, details?: object) => void }} MailTimeOptions
|
|
191
244
|
*/
|
|
192
245
|
|
|
193
246
|
/** Class of MailTime */
|
|
@@ -248,8 +301,37 @@ class MailTime {
|
|
|
248
301
|
this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
|
|
249
302
|
this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
|
|
250
303
|
this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
|
|
251
|
-
this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
|
|
304
|
+
this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && Number.isFinite(opts.sendingTimeout) && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
|
|
305
|
+
if (this.sendingTimeout < MIN_SAFE_SENDING_TIMEOUT) {
|
|
306
|
+
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.`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Claim renewal: MailTime has no way to know how long a transport will take, so a
|
|
310
|
+
// long-but-healthy send used to lose its lock to a "recovery" worker. While a send
|
|
311
|
+
// is in flight the claim is re-stamped every {renewClaim} ms — bounded by
|
|
312
|
+
// {maxRenewals} so a genuinely wedged send is still recovered, just later.
|
|
313
|
+
if (opts.renewClaim === false || opts.renewClaim === 0) {
|
|
314
|
+
this.renewClaim = 0;
|
|
315
|
+
} else if (typeof opts.renewClaim === 'number' && Number.isFinite(opts.renewClaim) && opts.renewClaim > 0) {
|
|
316
|
+
this.renewClaim = Math.floor(opts.renewClaim);
|
|
317
|
+
} else {
|
|
318
|
+
this.renewClaim = Math.max(1000, Math.floor(this.sendingTimeout / 3));
|
|
319
|
+
}
|
|
320
|
+
this.maxRenewals = (typeof opts.maxRenewals === 'number' && Number.isFinite(opts.maxRenewals) && opts.maxRenewals >= 0) ? Math.floor(opts.maxRenewals) : DEFAULT_MAX_RENEWALS;
|
|
321
|
+
|
|
322
|
+
// Transport fail-over policy. Rotating on *any* error can resubmit a letter the
|
|
323
|
+
// MX already accepted; a transport that knows better says so.
|
|
324
|
+
this.shouldFailOver = (typeof opts.shouldFailOver === 'function') ? opts.shouldFailOver.bind(this) : null;
|
|
325
|
+
|
|
326
|
+
this.strictPayload = opts.strictPayload === true;
|
|
327
|
+
this.allowedMailFields = new Set([
|
|
328
|
+
...STRICT_MAIL_FIELDS,
|
|
329
|
+
...(Array.isArray(opts.allowedMailFields) ? opts.allowedMailFields.filter((field) => typeof field === 'string') : []),
|
|
330
|
+
]);
|
|
331
|
+
|
|
252
332
|
this.__isDestroyed = false;
|
|
333
|
+
this.__abortInFlight = false;
|
|
334
|
+
this.__isPaused = false;
|
|
253
335
|
this.__readyPromise = null;
|
|
254
336
|
this.__schedulerTimer = null;
|
|
255
337
|
this.__inFlight = new Set();
|
|
@@ -305,6 +387,8 @@ class MailTime {
|
|
|
305
387
|
this.__debug(`INITIALIZING [mode: ${this.mode}]`);
|
|
306
388
|
this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
|
|
307
389
|
this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
|
|
390
|
+
this.__debug(`INITIALIZING [renewClaim: ${this.renewClaim}]`);
|
|
391
|
+
this.__debug(`INITIALIZING [strictPayload: ${this.strictPayload}]`);
|
|
308
392
|
|
|
309
393
|
/** SERVER-SPECIFIC CHECKS AND CONFIG */
|
|
310
394
|
if (this.type === 'server') {
|
|
@@ -369,6 +453,7 @@ class MailTime {
|
|
|
369
453
|
};
|
|
370
454
|
}
|
|
371
455
|
|
|
456
|
+
/** @type {MailTimeScheduler | undefined} */
|
|
372
457
|
this.scheduler = new JoSk({
|
|
373
458
|
debug: this.debug,
|
|
374
459
|
...this.josk,
|
|
@@ -402,10 +487,11 @@ class MailTime {
|
|
|
402
487
|
if (this.scheduler) {
|
|
403
488
|
const schedulerPing = await this.scheduler.ping();
|
|
404
489
|
if (schedulerPing.status !== 'OK') {
|
|
405
|
-
return schedulerPing;
|
|
490
|
+
return { ...schedulerPing, paused: this.__isPaused };
|
|
406
491
|
}
|
|
407
492
|
}
|
|
408
|
-
|
|
493
|
+
const queuePing = await this.queue.ping();
|
|
494
|
+
return { ...queuePing, paused: this.__isPaused };
|
|
409
495
|
}
|
|
410
496
|
|
|
411
497
|
/**
|
|
@@ -423,7 +509,7 @@ class MailTime {
|
|
|
423
509
|
/**
|
|
424
510
|
* @memberOf MailTime
|
|
425
511
|
* @name destroy
|
|
426
|
-
* @description Stop the scheduler and block future dispatches. Pass `{ drain: true }` to
|
|
512
|
+
* @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, any in-flight SMTP attempts are neutralized on completion — they perform no storage writes, no `onSent`/`onError` callbacks, and no logging once `destroy()` returns; their claimed rows are recovered by stale-lock timeout (`sendingTimeout`). Pass `{ drain: true }` to instead let in-flight attempts run to completion (returns a Promise that resolves once they settle).
|
|
427
513
|
* @param {{ drain?: boolean }} [opts]
|
|
428
514
|
* @returns {boolean | Promise<boolean>}
|
|
429
515
|
*/
|
|
@@ -434,12 +520,21 @@ class MailTime {
|
|
|
434
520
|
}
|
|
435
521
|
|
|
436
522
|
this.__isDestroyed = true;
|
|
523
|
+
this.__isPaused = false;
|
|
437
524
|
if (this.scheduler && typeof this.scheduler.destroy === 'function') {
|
|
438
525
|
this.scheduler.destroy();
|
|
439
526
|
}
|
|
440
527
|
if (opts && opts.drain === true && this.__pool) {
|
|
441
528
|
return this.__pool.drain().then(() => true);
|
|
442
529
|
}
|
|
530
|
+
// No graceful drain requested: neutralize in-flight SMTP completions so a
|
|
531
|
+
// destroyed instance performs no storage writes, no `onSent`/`onError`
|
|
532
|
+
// callbacks, and no logging after teardown. Each claimed row keeps its
|
|
533
|
+
// `isSending` lock, which a peer or a future instance recovers once
|
|
534
|
+
// `sendingTimeout` elapses — identical to a crash mid-send. Use
|
|
535
|
+
// `destroy({ drain: true })` (or `await drain()` before `destroy()`) when
|
|
536
|
+
// in-flight sends must run to completion.
|
|
537
|
+
this.__abortInFlight = true;
|
|
443
538
|
return true;
|
|
444
539
|
}
|
|
445
540
|
|
|
@@ -457,6 +552,52 @@ class MailTime {
|
|
|
457
552
|
}
|
|
458
553
|
}
|
|
459
554
|
|
|
555
|
+
/**
|
|
556
|
+
* @memberOf MailTime
|
|
557
|
+
* @name pause
|
|
558
|
+
* @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();`.
|
|
559
|
+
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
560
|
+
*/
|
|
561
|
+
pause() {
|
|
562
|
+
this.__debug('[pause]');
|
|
563
|
+
if (this.__isDestroyed || !this.scheduler) {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
const paused = this.scheduler.pause();
|
|
567
|
+
if (paused) {
|
|
568
|
+
this.__isPaused = true;
|
|
569
|
+
}
|
|
570
|
+
return paused;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* @memberOf MailTime
|
|
575
|
+
* @name resume
|
|
576
|
+
* @description Resume competing for the queue-drain lease after `pause()`; triggers an immediate scan. No-op on `client` instances, after `destroy()`, or when not paused.
|
|
577
|
+
* @returns {boolean} `true` if newly resumed; `false` if not paused, a client instance, or destroyed
|
|
578
|
+
*/
|
|
579
|
+
resume() {
|
|
580
|
+
this.__debug('[resume]');
|
|
581
|
+
if (this.__isDestroyed || !this.scheduler) {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
const resumed = this.scheduler.resume();
|
|
585
|
+
if (resumed) {
|
|
586
|
+
this.__isPaused = false;
|
|
587
|
+
}
|
|
588
|
+
return resumed;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* @memberOf MailTime
|
|
593
|
+
* @name isPaused
|
|
594
|
+
* @description Whether this instance is currently paused from draining the queue. Always `false` on `client` instances.
|
|
595
|
+
* @returns {boolean}
|
|
596
|
+
*/
|
|
597
|
+
get isPaused() {
|
|
598
|
+
return this.__isPaused;
|
|
599
|
+
}
|
|
600
|
+
|
|
460
601
|
/**
|
|
461
602
|
* @memberOf MailTime
|
|
462
603
|
* @name send
|
|
@@ -478,12 +619,17 @@ class MailTime {
|
|
|
478
619
|
* @returns {Promise<string>} uuid of the email
|
|
479
620
|
* @throws {Error}
|
|
480
621
|
*/
|
|
481
|
-
async sendMail(opts
|
|
622
|
+
async sendMail(opts) {
|
|
623
|
+
opts = (opts && typeof opts === 'object') ? opts : {};
|
|
482
624
|
this.__debug('[sendMail]', opts);
|
|
483
625
|
if (!opts.html && !opts.text) {
|
|
484
626
|
throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
|
|
485
627
|
}
|
|
486
628
|
|
|
629
|
+
if (opts.raw !== void 0) {
|
|
630
|
+
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.');
|
|
631
|
+
}
|
|
632
|
+
|
|
487
633
|
let sendAt = opts.sendAt;
|
|
488
634
|
if (sendAt instanceof Date) {
|
|
489
635
|
sendAt = +sendAt;
|
|
@@ -574,6 +720,10 @@ class MailTime {
|
|
|
574
720
|
*/
|
|
575
721
|
async ___handleError(task, error, info) {
|
|
576
722
|
this.__debug('[private handleError]', { task, error, info });
|
|
723
|
+
if (this.__abortInFlight) {
|
|
724
|
+
this.__debug('[private handleError] instance destroyed; skipping onError', task?.uuid);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
577
727
|
if (!task) {
|
|
578
728
|
return;
|
|
579
729
|
}
|
|
@@ -608,13 +758,16 @@ class MailTime {
|
|
|
608
758
|
task.isSending = false;
|
|
609
759
|
|
|
610
760
|
this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
|
|
611
|
-
this.onError
|
|
761
|
+
callHook('onError', this.onError, error, task, info);
|
|
612
762
|
return;
|
|
613
763
|
}
|
|
614
764
|
|
|
615
765
|
let transportIndex = task.transport;
|
|
616
766
|
|
|
617
|
-
if (this.strategy === 'backup'
|
|
767
|
+
if (this.strategy === 'backup'
|
|
768
|
+
&& this.transports.length > 1
|
|
769
|
+
&& (task.tries % this.failsToNext) === 0
|
|
770
|
+
&& this.___mayFailOver(error, info, task)) {
|
|
618
771
|
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
619
772
|
}
|
|
620
773
|
|
|
@@ -676,18 +829,24 @@ class MailTime {
|
|
|
676
829
|
* @internal
|
|
677
830
|
* @memberOf MailTime
|
|
678
831
|
* @name ___render
|
|
679
|
-
* @description Render Mustache-like placeholders
|
|
832
|
+
* @description Render Mustache-like placeholders. `{{{key}}}` is always verbatim.
|
|
833
|
+
* `{{key}}` is HTML-escaped in HTML contexts (`html`, `template`, `concatDelimiter`) —
|
|
834
|
+
* that is what double-brace means everywhere else, and the previous strip-tags pass was
|
|
835
|
+
* not a safety net: it needs a closing `>`, so an unterminated `<a href="…` survived
|
|
836
|
+
* into the rendered body. Text bodies and subject headers are not HTML, so `{{key}}`
|
|
837
|
+
* substitutes verbatim there.
|
|
680
838
|
* @param {string} _string - Template with Mustache-like placeholders
|
|
681
839
|
* @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
|
|
840
|
+
* @param {boolean} [isHtml=true] - `false` for text/plain bodies and header values
|
|
682
841
|
* @returns {string}
|
|
683
842
|
*/
|
|
684
|
-
___render(_string, replacements) {
|
|
843
|
+
___render(_string, replacements, isHtml = true) {
|
|
685
844
|
let string = _string;
|
|
686
845
|
const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
|
|
687
846
|
if (matchHTML) {
|
|
688
847
|
for (let i = 0; i < matchHTML.length; i++) {
|
|
689
848
|
const key = matchHTML[i].slice(3, -3).trim();
|
|
690
|
-
if (
|
|
849
|
+
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
691
850
|
string = string.replace(matchHTML[i], `${replacements[key]}`);
|
|
692
851
|
}
|
|
693
852
|
}
|
|
@@ -697,8 +856,9 @@ class MailTime {
|
|
|
697
856
|
if (matchStr) {
|
|
698
857
|
for (let i = 0; i < matchStr.length; i++) {
|
|
699
858
|
const key = matchStr[i].slice(2, -2).trim();
|
|
700
|
-
if (
|
|
701
|
-
|
|
859
|
+
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
860
|
+
const value = `${replacements[key]}`;
|
|
861
|
+
string = string.replace(matchStr[i], isHtml ? escapeHtml(value) : value);
|
|
702
862
|
}
|
|
703
863
|
}
|
|
704
864
|
}
|
|
@@ -737,7 +897,7 @@ class MailTime {
|
|
|
737
897
|
const isMulti = mailOptionsList.length > 1;
|
|
738
898
|
|
|
739
899
|
for (let i = 0; i < mailOptionsList.length; i++) {
|
|
740
|
-
const mailOption = { ...mailOptionsList[i] };
|
|
900
|
+
const mailOption = this.___applyPayloadPolicy({ ...mailOptionsList[i] });
|
|
741
901
|
|
|
742
902
|
if (mailOption.html) {
|
|
743
903
|
const rendered = this.___render(mailOption.html, mailOption);
|
|
@@ -750,7 +910,7 @@ class MailTime {
|
|
|
750
910
|
}
|
|
751
911
|
|
|
752
912
|
if (mailOption.text) {
|
|
753
|
-
const rendered = this.___render(mailOption.text, mailOption);
|
|
913
|
+
const rendered = this.___render(mailOption.text, mailOption, false);
|
|
754
914
|
if (isMulti) {
|
|
755
915
|
compiledOpts.text += '\r\n' + rendered;
|
|
756
916
|
} else {
|
|
@@ -768,11 +928,14 @@ class MailTime {
|
|
|
768
928
|
|
|
769
929
|
if (isMulti) {
|
|
770
930
|
const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
|
|
771
|
-
compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
|
|
931
|
+
compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length }, false);
|
|
772
932
|
}
|
|
773
933
|
|
|
774
934
|
if (!compiledOpts.from && this.from) {
|
|
775
|
-
compiledOpts.from = this.from(transport
|
|
935
|
+
compiledOpts.from = this.from(transport, {
|
|
936
|
+
index: this.transports.indexOf(transport),
|
|
937
|
+
from: MailTime.transportFrom(transport),
|
|
938
|
+
});
|
|
776
939
|
}
|
|
777
940
|
|
|
778
941
|
const acceptedSet = collectAcceptedSet(task);
|
|
@@ -782,9 +945,84 @@ class MailTime {
|
|
|
782
945
|
compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
|
|
783
946
|
}
|
|
784
947
|
|
|
948
|
+
if (this.strictPayload) {
|
|
949
|
+
compiledOpts.disableFileAccess = true;
|
|
950
|
+
compiledOpts.disableUrlAccess = true;
|
|
951
|
+
}
|
|
952
|
+
|
|
785
953
|
return compiledOpts;
|
|
786
954
|
}
|
|
787
955
|
|
|
956
|
+
/**
|
|
957
|
+
* @internal
|
|
958
|
+
* @memberOf MailTime
|
|
959
|
+
* @name ___applyPayloadPolicy
|
|
960
|
+
* @description Narrow one queued mail option to what a task is allowed to set. `raw` is
|
|
961
|
+
* always dropped: it bypasses composition (so `template`, `concatEmails` and the
|
|
962
|
+
* `from()` callback silently stop applying) and historically bypassed nodemailer's
|
|
963
|
+
* `disableFileAccess`/`disableUrlAccess` with it. Under `{strictPayload: true}` only
|
|
964
|
+
* `allowedMailFields` survive.
|
|
965
|
+
* @param {MailTimeMailOptions} mailOption - a single queued mail option object
|
|
966
|
+
* @returns {MailTimeMailOptions}
|
|
967
|
+
*/
|
|
968
|
+
___applyPayloadPolicy(mailOption) {
|
|
969
|
+
if (mailOption.raw !== void 0) {
|
|
970
|
+
delete mailOption.raw;
|
|
971
|
+
logError('[___compileMailOpts] dropped {raw} from a queued task — `raw` bypasses composition and is never sent by MailTime');
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (!this.strictPayload) {
|
|
975
|
+
return mailOption;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const allowed = {};
|
|
979
|
+
for (const key of Object.keys(mailOption)) {
|
|
980
|
+
if (this.allowedMailFields.has(key)) {
|
|
981
|
+
allowed[key] = mailOption[key];
|
|
982
|
+
} else {
|
|
983
|
+
this.__debug('[___applyPayloadPolicy] dropped non-allowlisted field', key);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return allowed;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* @static
|
|
991
|
+
* @memberOf MailTime
|
|
992
|
+
* @name transportFrom
|
|
993
|
+
* @description Best-effort sender address for a transport. `nodemailer.createTransport()`
|
|
994
|
+
* only exposes `.options` for *plain-object* configs — for a class-instance transporter
|
|
995
|
+
* (anything with its own `.send()`) nodemailer's internal `Mail.options` is always `{}`,
|
|
996
|
+
* so `transport.options.from` silently reads `undefined`. This walks the places the
|
|
997
|
+
* address can actually live.
|
|
998
|
+
* @param {MailTimeTransport} transport
|
|
999
|
+
* @returns {string | undefined}
|
|
1000
|
+
*/
|
|
1001
|
+
static transportFrom(transport) {
|
|
1002
|
+
const candidates = [
|
|
1003
|
+
transport?.options?.from,
|
|
1004
|
+
transport?.transporter?.options?.from,
|
|
1005
|
+
transport?._defaults?.from,
|
|
1006
|
+
transport?._options?.from,
|
|
1007
|
+
];
|
|
1008
|
+
|
|
1009
|
+
for (const candidate of candidates) {
|
|
1010
|
+
// nodemailer accepts `from` as a string or as `{ name, address }`. Resolve the
|
|
1011
|
+
// first candidate that yields a usable address — not the first truthy slot, so a
|
|
1012
|
+
// malformed `{ name }` object cannot mask a real address further down the chain.
|
|
1013
|
+
const address = (typeof candidate === 'string')
|
|
1014
|
+
? candidate
|
|
1015
|
+
: (candidate && typeof candidate === 'object' && typeof candidate.address === 'string')
|
|
1016
|
+
? candidate.address
|
|
1017
|
+
: '';
|
|
1018
|
+
if (address.trim()) {
|
|
1019
|
+
return address;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
return void 0;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
788
1026
|
/**
|
|
789
1027
|
* @internal
|
|
790
1028
|
* @memberOf MailTime
|
|
@@ -849,6 +1087,124 @@ class MailTime {
|
|
|
849
1087
|
}
|
|
850
1088
|
}
|
|
851
1089
|
|
|
1090
|
+
/**
|
|
1091
|
+
* @internal
|
|
1092
|
+
* @memberOf MailTime
|
|
1093
|
+
* @name ___mayFailOver
|
|
1094
|
+
* @description Whether this failure may be retried on the *next* transport. Rotating on
|
|
1095
|
+
* any error can resubmit a letter the receiving MTA already accepted (or may have
|
|
1096
|
+
* accepted — a socket that died during `DATA` is indistinguishable from one that died
|
|
1097
|
+
* before it). A transport signals "do not fail over" with `error.mayFailOver = false`;
|
|
1098
|
+
* the `shouldFailOver(error, info, task)` option overrides both.
|
|
1099
|
+
* @param {unknown} error - error returned by the transport
|
|
1100
|
+
* @param {object|undefined} info - info object returned by the transport
|
|
1101
|
+
* @param {MailTimeTask} task - email's task object from Storage
|
|
1102
|
+
* @returns {boolean}
|
|
1103
|
+
*/
|
|
1104
|
+
___mayFailOver(error, info, task) {
|
|
1105
|
+
if (this.shouldFailOver) {
|
|
1106
|
+
try {
|
|
1107
|
+
return this.shouldFailOver(error, info, task) !== false;
|
|
1108
|
+
} catch (policyError) {
|
|
1109
|
+
logError('[shouldFailOver] callback failed; keeping the current transport', policyError);
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return !(error && typeof error === 'object' && error.mayFailOver === false);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* @internal
|
|
1118
|
+
* @memberOf MailTime
|
|
1119
|
+
* @name ___startClaimRenewal
|
|
1120
|
+
* @description Hold a claim alive while its SMTP roundtrip runs. MailTime 4.x stamped
|
|
1121
|
+
* `sendingAt` once at claim time, so any send slower than `sendingTimeout` lost its lock
|
|
1122
|
+
* to a "recovery" worker and the letter went out twice. The renewal is a lease-guarded
|
|
1123
|
+
* update — it succeeds only while *this* worker still owns the row — and is bounded by
|
|
1124
|
+
* `maxRenewals` so a genuinely wedged send is still recovered.
|
|
1125
|
+
* @param {MailTimeTask} task - claimed task, with `isSending`/`sendingAt`/`tries` set
|
|
1126
|
+
* @returns {{ stop: () => Promise<void> }}
|
|
1127
|
+
*/
|
|
1128
|
+
___startClaimRenewal(task) {
|
|
1129
|
+
if (!this.renewClaim || !this.maxRenewals || this.type !== 'server') {
|
|
1130
|
+
return { stop: async () => {} };
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
let stopped = false;
|
|
1134
|
+
let renewals = 0;
|
|
1135
|
+
let timer = null;
|
|
1136
|
+
let activeRenewal = null;
|
|
1137
|
+
const halt = () => {
|
|
1138
|
+
if (stopped) {
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
stopped = true;
|
|
1142
|
+
if (timer) {
|
|
1143
|
+
clearInterval(timer);
|
|
1144
|
+
timer = null;
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
const stop = async () => {
|
|
1148
|
+
halt();
|
|
1149
|
+
if (activeRenewal) {
|
|
1150
|
+
await activeRenewal;
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
const renew = async () => {
|
|
1155
|
+
if (stopped || this.__isDestroyed || this.__abortInFlight) {
|
|
1156
|
+
halt();
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (renewals >= this.maxRenewals) {
|
|
1161
|
+
this.__debug('[private renewClaim] renewal budget exhausted; letting the claim go stale', task.uuid);
|
|
1162
|
+
halt();
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
renewals += 1;
|
|
1166
|
+
|
|
1167
|
+
// Strictly greater than the current stamp: storages that report "modified" only on
|
|
1168
|
+
// an actual value change would otherwise read a same-millisecond renewal as a no-op.
|
|
1169
|
+
const renewedAt = Math.max(Date.now(), (typeof task.sendingAt === 'number' ? task.sendingAt : 0) + 1);
|
|
1170
|
+
let renewed = false;
|
|
1171
|
+
try {
|
|
1172
|
+
renewed = await this.queue.update(task, __withLease(task, {
|
|
1173
|
+
isSending: true,
|
|
1174
|
+
sendingAt: renewedAt,
|
|
1175
|
+
}));
|
|
1176
|
+
} catch (renewError) {
|
|
1177
|
+
logError('[private renewClaim] storage error during claim renewal', renewError);
|
|
1178
|
+
halt();
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (!renewed) {
|
|
1183
|
+
this.__debug('[private renewClaim] lease lost; stopping renewal', task.uuid);
|
|
1184
|
+
halt();
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
task.sendingAt = renewedAt;
|
|
1189
|
+
this.__debug(`[private renewClaim] renewed ${renewals}/${this.maxRenewals}`, task.uuid);
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
timer = setInterval(() => {
|
|
1193
|
+
if (activeRenewal) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
activeRenewal = renew().finally(() => {
|
|
1197
|
+
activeRenewal = null;
|
|
1198
|
+
});
|
|
1199
|
+
}, this.renewClaim);
|
|
1200
|
+
|
|
1201
|
+
if (typeof timer.unref === 'function') {
|
|
1202
|
+
timer.unref();
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return { stop };
|
|
1206
|
+
}
|
|
1207
|
+
|
|
852
1208
|
/**
|
|
853
1209
|
* @async
|
|
854
1210
|
* @internal
|
|
@@ -929,127 +1285,147 @@ class MailTime {
|
|
|
929
1285
|
const transport = this.transports[transportIndex];
|
|
930
1286
|
|
|
931
1287
|
const compiledOpts = this.___compileMailOpts(transport, task);
|
|
1288
|
+
const renewal = this.___startClaimRenewal(task);
|
|
932
1289
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1290
|
+
try {
|
|
1291
|
+
await new Promise((resolve) => {
|
|
1292
|
+
transport.sendMail(compiledOpts, async (error, info) => {
|
|
1293
|
+
// The roundtrip has settled — stop extending the lease before any completion
|
|
1294
|
+
// write, so the guard below sees the last `sendingAt` this worker persisted.
|
|
1295
|
+
await renewal.stop();
|
|
1296
|
+
if (this.__abortInFlight) {
|
|
1297
|
+
this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
|
|
1298
|
+
resolve();
|
|
939
1299
|
return;
|
|
940
1300
|
}
|
|
1301
|
+
this.__debug('[private send] [sending]', { error, info });
|
|
1302
|
+
try {
|
|
1303
|
+
if (error) {
|
|
1304
|
+
await this.___handleError(task, error, info);
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
941
1307
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1308
|
+
const acceptedAddrs = Array.isArray(info?.accepted)
|
|
1309
|
+
? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
|
|
1310
|
+
: [];
|
|
945
1311
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1312
|
+
if (acceptedAddrs.length === 0) {
|
|
1313
|
+
await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
950
1316
|
|
|
951
|
-
|
|
1317
|
+
this.___trackAcceptedRecipients(task, acceptedAddrs);
|
|
952
1318
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1319
|
+
const allRecipients = collectAllRecipients(task);
|
|
1320
|
+
const allAccepted = collectAcceptedSet(task);
|
|
1321
|
+
let isFullyDelivered = true;
|
|
1322
|
+
for (const addr of allRecipients) {
|
|
1323
|
+
if (!allAccepted.has(addr)) {
|
|
1324
|
+
isFullyDelivered = false;
|
|
1325
|
+
break;
|
|
1326
|
+
}
|
|
960
1327
|
}
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
if (isFullyDelivered) {
|
|
964
|
-
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
965
1328
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1329
|
+
if (isFullyDelivered) {
|
|
1330
|
+
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
1331
|
+
|
|
1332
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
1333
|
+
const leaseUpdate = __withLease(task, {
|
|
1334
|
+
isSent: true,
|
|
1335
|
+
isSending: false,
|
|
1336
|
+
sendingAt: 0,
|
|
1337
|
+
mailOptions: task.mailOptions,
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
let completed = false;
|
|
1341
|
+
if (!this.keepHistory) {
|
|
1342
|
+
completed = await this.queue.remove(task, leaseRemove);
|
|
1343
|
+
} else {
|
|
1344
|
+
completed = await this.queue.update(task, leaseUpdate);
|
|
1345
|
+
}
|
|
973
1346
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
completed = await this.queue.update(task, leaseUpdate);
|
|
979
|
-
}
|
|
1347
|
+
if (!completed) {
|
|
1348
|
+
this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
980
1351
|
|
|
981
|
-
|
|
982
|
-
|
|
1352
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
|
|
1353
|
+
task.isSent = true;
|
|
1354
|
+
task.isSending = false;
|
|
1355
|
+
task.sendingAt = 0;
|
|
1356
|
+
callHook('onSent', this.onSent, task, info);
|
|
983
1357
|
return;
|
|
984
1358
|
}
|
|
985
1359
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1360
|
+
if (task.tries >= this.maxTries) {
|
|
1361
|
+
this.___finalizeRejected(task, info);
|
|
1362
|
+
|
|
1363
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
1364
|
+
const leaseUpdate = __withLease(task, {
|
|
1365
|
+
isSent: false,
|
|
1366
|
+
isFailed: true,
|
|
1367
|
+
isSending: false,
|
|
1368
|
+
sendingAt: 0,
|
|
1369
|
+
mailOptions: task.mailOptions,
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
let finalized = false;
|
|
1373
|
+
if (!this.keepHistory) {
|
|
1374
|
+
finalized = await this.queue.remove(task, leaseRemove);
|
|
1375
|
+
} else {
|
|
1376
|
+
finalized = await this.queue.update(task, leaseUpdate);
|
|
1377
|
+
}
|
|
996
1378
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
isSending: false,
|
|
1002
|
-
sendingAt: 0,
|
|
1003
|
-
mailOptions: task.mailOptions,
|
|
1004
|
-
});
|
|
1379
|
+
if (!finalized) {
|
|
1380
|
+
this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1005
1383
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
1011
|
-
}
|
|
1384
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
|
|
1385
|
+
task.isSent = false;
|
|
1386
|
+
task.isFailed = true;
|
|
1387
|
+
task.isSending = false;
|
|
1012
1388
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1389
|
+
const rejectedAddrs = [];
|
|
1390
|
+
for (const mo of task.mailOptions) {
|
|
1391
|
+
for (const r of (mo.rejected || [])) {
|
|
1392
|
+
rejectedAddrs.push(r.address);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
|
|
1396
|
+
this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
|
|
1397
|
+
callHook('onError', this.onError, partialError, task, info);
|
|
1015
1398
|
return;
|
|
1016
1399
|
}
|
|
1017
1400
|
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1401
|
+
const nextSendAt = Date.now() + this.retryDelay;
|
|
1402
|
+
const released = await this.queue.update(task, __withLease(task, {
|
|
1403
|
+
isSending: false,
|
|
1404
|
+
sendingAt: 0,
|
|
1405
|
+
sendAt: nextSendAt,
|
|
1406
|
+
mailOptions: task.mailOptions,
|
|
1407
|
+
}));
|
|
1408
|
+
if (!released) {
|
|
1409
|
+
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
1410
|
+
return;
|
|
1028
1411
|
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1412
|
+
this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
|
|
1413
|
+
} catch (completionError) {
|
|
1414
|
+
logError('[private send] completion error after transport callback', completionError);
|
|
1415
|
+
} finally {
|
|
1416
|
+
resolve();
|
|
1033
1417
|
}
|
|
1034
|
-
|
|
1035
|
-
const nextSendAt = Date.now() + this.retryDelay;
|
|
1036
|
-
const released = await this.queue.update(task, __withLease(task, {
|
|
1037
|
-
isSending: false,
|
|
1038
|
-
sendingAt: 0,
|
|
1039
|
-
sendAt: nextSendAt,
|
|
1040
|
-
mailOptions: task.mailOptions,
|
|
1041
|
-
}));
|
|
1042
|
-
if (!released) {
|
|
1043
|
-
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
1044
|
-
return;
|
|
1045
|
-
}
|
|
1046
|
-
this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
|
|
1047
|
-
} finally {
|
|
1048
|
-
resolve();
|
|
1049
|
-
}
|
|
1418
|
+
});
|
|
1050
1419
|
});
|
|
1051
|
-
}
|
|
1420
|
+
} finally {
|
|
1421
|
+
// A transport that throws synchronously never reaches the callback above.
|
|
1422
|
+
await renewal.stop();
|
|
1423
|
+
}
|
|
1052
1424
|
} catch (e) {
|
|
1425
|
+
if (this.__abortInFlight) {
|
|
1426
|
+
this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1053
1429
|
logError('Exception during runtime:', e);
|
|
1054
1430
|
await this.___handleError(task, e, {});
|
|
1055
1431
|
}
|
|
@@ -1064,7 +1440,7 @@ class MailTime {
|
|
|
1064
1440
|
*/
|
|
1065
1441
|
async ___iterate() {
|
|
1066
1442
|
this.__debug('[private iterate]');
|
|
1067
|
-
if (this.__isDestroyed) {
|
|
1443
|
+
if (this.__isDestroyed || this.__isPaused) {
|
|
1068
1444
|
return;
|
|
1069
1445
|
}
|
|
1070
1446
|
const limit = this.mode === 'one' ? 1 : Infinity;
|
|
@@ -1131,7 +1507,7 @@ class MailTime {
|
|
|
1131
1507
|
}
|
|
1132
1508
|
this.__unhealthyTransports.add(r.index);
|
|
1133
1509
|
logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
|
|
1134
|
-
this.onError
|
|
1510
|
+
callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
|
|
1135
1511
|
}
|
|
1136
1512
|
|
|
1137
1513
|
if (this.__unhealthyTransports.size === this.transports.length) {
|