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.cjs
CHANGED
|
@@ -1,20 +1,48 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const josk = require('josk');
|
|
4
|
-
const
|
|
4
|
+
const crypto = require('crypto');
|
|
5
5
|
|
|
6
6
|
const hasOwn = Object.prototype.hasOwnProperty;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* @name hasOwnProp - `Object.hasOwn` (ES2022) polyfill.
|
|
10
|
+
* @function
|
|
11
|
+
* @param {object} obj
|
|
12
|
+
* @param {PropertyKey} key
|
|
13
|
+
* @returns {boolean} `true` if the object has the property, `false` otherwise.
|
|
14
|
+
*/
|
|
15
|
+
const hasOwnProp = (obj, key) => hasOwn.call(obj, key);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @name debug - Debug logging.
|
|
19
|
+
* @function
|
|
20
|
+
* @param {boolean} isDebug
|
|
21
|
+
* @param {...any} args
|
|
22
|
+
* @returns {void}
|
|
23
|
+
*/
|
|
8
24
|
const debug = (isDebug, ...args) => {
|
|
9
25
|
if (isDebug) {
|
|
10
26
|
console.info('[DEBUG] [mail-time]', `${new Date()}`, ...args);
|
|
11
27
|
}
|
|
12
28
|
};
|
|
13
29
|
|
|
30
|
+
/**
|
|
31
|
+
* @name logError - Error logging.
|
|
32
|
+
* @function
|
|
33
|
+
* @param {...any} args
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
14
36
|
const logError = (...args) => {
|
|
15
37
|
console.error('[ERROR] [mail-time]', `${new Date()}`, ...args);
|
|
16
38
|
};
|
|
17
39
|
|
|
40
|
+
/**
|
|
41
|
+
* @name isPlainObject - Check whether a value is a plain object (literal or `Object.create(null)`).
|
|
42
|
+
* @function
|
|
43
|
+
* @param {any} value
|
|
44
|
+
* @returns {boolean} `true` for plain objects, `false` for `null`, arrays, class instances, and primitives.
|
|
45
|
+
*/
|
|
18
46
|
const isPlainObject = (value) => {
|
|
19
47
|
if (value === null || typeof value !== 'object') {
|
|
20
48
|
return false;
|
|
@@ -24,11 +52,15 @@ const isPlainObject = (value) => {
|
|
|
24
52
|
};
|
|
25
53
|
|
|
26
54
|
/**
|
|
27
|
-
* Minimal deep-merge sufficient for nodemailer-shaped mail options:
|
|
55
|
+
* @name deepMerge - Minimal deep-merge sufficient for nodemailer-shaped mail options:
|
|
28
56
|
* - plain objects merge key-by-key
|
|
29
57
|
* - arrays concatenate
|
|
30
58
|
* - other values (strings, numbers, Date, Buffer, streams, classes) replace
|
|
31
59
|
* Source values override target values.
|
|
60
|
+
* @function
|
|
61
|
+
* @param {any} target - Base value; merged into a shallow clone when a plain object, otherwise ignored.
|
|
62
|
+
* @param {any} source - Overriding value; returns `target` unchanged when not a plain object.
|
|
63
|
+
* @returns {any} The merged result (a new object), or `target` when `source` is not a plain object.
|
|
32
64
|
*/
|
|
33
65
|
const deepMerge = (target, source) => {
|
|
34
66
|
if (!isPlainObject(source)) {
|
|
@@ -54,9 +86,13 @@ const deepMerge = (target, source) => {
|
|
|
54
86
|
};
|
|
55
87
|
|
|
56
88
|
/**
|
|
57
|
-
* Order-insensitive deep equality. Treats arrays as multisets and
|
|
89
|
+
* @name equals - Order-insensitive deep equality. Treats arrays as multisets and
|
|
58
90
|
* objects as unordered maps. Designed for the small `mailOptions`
|
|
59
91
|
* shape used by MailTime's email concatenation dedup.
|
|
92
|
+
* @function
|
|
93
|
+
* @param {any} a
|
|
94
|
+
* @param {any} b
|
|
95
|
+
* @returns {boolean} `true` when `a` and `b` are deeply equal ignoring array/key order.
|
|
60
96
|
*/
|
|
61
97
|
const equals = (a, b) => {
|
|
62
98
|
if (a === b) {
|
|
@@ -115,9 +151,11 @@ const equals = (a, b) => {
|
|
|
115
151
|
};
|
|
116
152
|
|
|
117
153
|
/**
|
|
118
|
-
* Extract the email part of a nodemailer-shaped recipient entry.
|
|
154
|
+
* @name extractEmail - Extract the email part of a nodemailer-shaped recipient entry.
|
|
119
155
|
* Accepts `'a@x.com'`, `'Name <a@x.com>'`, or `{ name, address }`.
|
|
120
|
-
*
|
|
156
|
+
* @function
|
|
157
|
+
* @param {string|{name?: string, address?: string}|null|undefined} entry
|
|
158
|
+
* @returns {string|null} The address lowercased and trimmed, or `null` when none can be parsed.
|
|
121
159
|
*/
|
|
122
160
|
const extractEmail = (entry) => {
|
|
123
161
|
if (!entry) {
|
|
@@ -134,7 +172,10 @@ const extractEmail = (entry) => {
|
|
|
134
172
|
};
|
|
135
173
|
|
|
136
174
|
/**
|
|
137
|
-
* Normalize a `to`/`cc`/`bcc` field into a flat list of lowercase addresses.
|
|
175
|
+
* @name toAddressList - Normalize a `to`/`cc`/`bcc` field into a flat list of lowercase addresses.
|
|
176
|
+
* @function
|
|
177
|
+
* @param {string|Array<string|{name?: string, address?: string}>|null|undefined} field
|
|
178
|
+
* @returns {string[]} Flat list of parsed lowercase addresses; empty when `field` is falsy or unparseable.
|
|
138
179
|
*/
|
|
139
180
|
const toAddressList = (field) => {
|
|
140
181
|
if (!field) {
|
|
@@ -155,9 +196,13 @@ const toAddressList = (field) => {
|
|
|
155
196
|
};
|
|
156
197
|
|
|
157
198
|
/**
|
|
158
|
-
* Remove entries whose extracted address is in `acceptedSet` from a
|
|
159
|
-
* nodemailer `to`/`cc`/`bcc` field.
|
|
160
|
-
*
|
|
199
|
+
* @name filterAddressField - Remove entries whose extracted address is in `acceptedSet` from a
|
|
200
|
+
* nodemailer `to`/`cc`/`bcc` field.
|
|
201
|
+
* @function
|
|
202
|
+
* @param {string|Array<string|{name?: string, address?: string}>|null|undefined} field
|
|
203
|
+
* @param {Set<string>} acceptedSet - Lowercase addresses to drop.
|
|
204
|
+
* @returns {string|Array|undefined} The filtered field, or `void 0` when the filtered array would be
|
|
205
|
+
* empty or the single string is dropped. Returns `field` unchanged when `acceptedSet` is empty.
|
|
161
206
|
*/
|
|
162
207
|
const filterAddressField = (field, acceptedSet) => {
|
|
163
208
|
if (!field || !(acceptedSet instanceof Set) || acceptedSet.size === 0) {
|
|
@@ -180,19 +225,61 @@ const filterAddressField = (field, acceptedSet) => {
|
|
|
180
225
|
return field;
|
|
181
226
|
};
|
|
182
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
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @name isSendClaimUpdate - Detect an atomic send-claim update (`{ isSending: true, tries: N }`).
|
|
248
|
+
* @function
|
|
249
|
+
* @param {object} updateObj
|
|
250
|
+
* @returns {boolean} `true` when `updateObj` claims a row for sending.
|
|
251
|
+
*/
|
|
183
252
|
const isSendClaimUpdate = (updateObj) => {
|
|
184
253
|
return updateObj && updateObj.isSending === true && typeof updateObj.tries === 'number';
|
|
185
254
|
};
|
|
186
255
|
|
|
256
|
+
/**
|
|
257
|
+
* @name isSendLeaseGuardedUpdate - Detect an update guarded by a send lease
|
|
258
|
+
* (carries `leaseTries` and `leaseSendingAt`).
|
|
259
|
+
* @function
|
|
260
|
+
* @param {object} updateObj
|
|
261
|
+
* @returns {boolean} `true` when both lease guard fields are present numbers.
|
|
262
|
+
*/
|
|
187
263
|
const isSendLeaseGuardedUpdate = (updateObj) => {
|
|
188
264
|
return updateObj && typeof updateObj.leaseTries === 'number' && typeof updateObj.leaseSendingAt === 'number';
|
|
189
265
|
};
|
|
190
266
|
|
|
267
|
+
/**
|
|
268
|
+
* @name isAppendMailOptionUpdate - Detect an update that appends a mail option (for email concatenation).
|
|
269
|
+
* @function
|
|
270
|
+
* @param {object} updateObj
|
|
271
|
+
* @returns {boolean} `true` when `updateObj.appendMailOption` is set.
|
|
272
|
+
*/
|
|
191
273
|
const isAppendMailOptionUpdate = (updateObj) => {
|
|
192
274
|
return updateObj && updateObj.appendMailOption !== void 0;
|
|
193
275
|
};
|
|
194
276
|
|
|
195
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* @name stripInternalUpdateMeta - Strip MailTime-internal update keys before persisting to storage.
|
|
279
|
+
* @function
|
|
280
|
+
* @param {object} updateObj
|
|
281
|
+
* @returns {object} A shallow clone without `leaseTries`, `leaseSendingAt`, and `appendMailOption`.
|
|
282
|
+
*/
|
|
196
283
|
const stripInternalUpdateMeta = (updateObj) => {
|
|
197
284
|
const out = { ...updateObj };
|
|
198
285
|
delete out.leaseTries;
|
|
@@ -201,6 +288,13 @@ const stripInternalUpdateMeta = (updateObj) => {
|
|
|
201
288
|
return out;
|
|
202
289
|
};
|
|
203
290
|
|
|
291
|
+
/**
|
|
292
|
+
* @name isSendLeaseRemove - Detect a remove guarded by a send lease
|
|
293
|
+
* (carries `leaseTries` and `leaseSendingAt`).
|
|
294
|
+
* @function
|
|
295
|
+
* @param {object} opts
|
|
296
|
+
* @returns {boolean} `true` when both lease guard fields are present numbers.
|
|
297
|
+
*/
|
|
204
298
|
const isSendLeaseRemove = (opts) => {
|
|
205
299
|
return opts && typeof opts.leaseTries === 'number' && typeof opts.leaseSendingAt === 'number';
|
|
206
300
|
};
|
|
@@ -425,10 +519,13 @@ class MongoQueue {
|
|
|
425
519
|
cursor.limit(limit);
|
|
426
520
|
}
|
|
427
521
|
|
|
428
|
-
|
|
429
|
-
|
|
522
|
+
try {
|
|
523
|
+
while (await cursor.hasNext()) {
|
|
524
|
+
await this.mailTimeInstance.___dispatch(await cursor.next());
|
|
525
|
+
}
|
|
526
|
+
} finally {
|
|
527
|
+
await cursor.close().catch(() => {});
|
|
430
528
|
}
|
|
431
|
-
await cursor.close();
|
|
432
529
|
} catch (iterateError) {
|
|
433
530
|
logError('[iterate] [while/await] [iterateError]', iterateError);
|
|
434
531
|
}
|
|
@@ -1197,7 +1294,21 @@ class RedisQueue {
|
|
|
1197
1294
|
*/
|
|
1198
1295
|
|
|
1199
1296
|
const DEFAULT_PREFIX = 'default';
|
|
1200
|
-
|
|
1297
|
+
|
|
1298
|
+
// Two-key advisory lock, mirroring josk's PostgresAdapter: a stable MailTime namespace
|
|
1299
|
+
// plus a per-prefix hash. `pg_advisory_lock(int4, int4)` lives in its own keyspace, isolated
|
|
1300
|
+
// from single-int callers in the same database; distinct prefixes get distinct lock IDs, so
|
|
1301
|
+
// co-tenant MailTime queues no longer serialize each other's schema setup.
|
|
1302
|
+
const ADVISORY_LOCK_NAMESPACE = 0x4D61696C; // 'Mail' in ASCII as int32
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* @internal
|
|
1306
|
+
* @param {string} prefix
|
|
1307
|
+
* @returns {number} signed int32 hash of the prefix string
|
|
1308
|
+
*/
|
|
1309
|
+
const advisoryLockKeyFor = (prefix) => {
|
|
1310
|
+
return crypto.createHash('sha256').update(prefix).digest().readInt32BE(0);
|
|
1311
|
+
};
|
|
1201
1312
|
|
|
1202
1313
|
const fieldMap = {
|
|
1203
1314
|
to: 'to_address',
|
|
@@ -1300,7 +1411,8 @@ class PostgresQueue {
|
|
|
1300
1411
|
|
|
1301
1412
|
/** @internal */
|
|
1302
1413
|
async __setup() {
|
|
1303
|
-
|
|
1414
|
+
const advisoryLockKey = advisoryLockKeyFor(this.prefix);
|
|
1415
|
+
await this.client.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
|
|
1304
1416
|
|
|
1305
1417
|
try {
|
|
1306
1418
|
await this.client.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
|
|
@@ -1331,7 +1443,7 @@ class PostgresQueue {
|
|
|
1331
1443
|
await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_pending_to
|
|
1332
1444
|
ON mail_time_queue (prefix, to_address, is_sent, is_failed, is_cancelled, send_at)`);
|
|
1333
1445
|
} finally {
|
|
1334
|
-
await this.client.query('SELECT pg_advisory_unlock($1)', [
|
|
1446
|
+
await this.client.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
|
|
1335
1447
|
}
|
|
1336
1448
|
}
|
|
1337
1449
|
|
|
@@ -1713,7 +1825,7 @@ function defaultPresetOnError(error, email, info) {
|
|
|
1713
1825
|
* @property {number} [sendingTimeout]
|
|
1714
1826
|
* @property {'one' | 'batch'} [mode]
|
|
1715
1827
|
* @property {number} [concurrency]
|
|
1716
|
-
* @property {(error: unknown, email:
|
|
1828
|
+
* @property {(error: unknown, email: any, details?: object) => void} [onError]
|
|
1717
1829
|
* @property {object} [josk]
|
|
1718
1830
|
*/
|
|
1719
1831
|
|
|
@@ -1761,7 +1873,7 @@ const PRESETS = Object.freeze({
|
|
|
1761
1873
|
retries: 5,
|
|
1762
1874
|
retryDelay: 2000,
|
|
1763
1875
|
revolvingInterval: 1024,
|
|
1764
|
-
sendingTimeout:
|
|
1876
|
+
sendingTimeout: 120_000,
|
|
1765
1877
|
mode: 'batch',
|
|
1766
1878
|
concurrency: 4,
|
|
1767
1879
|
onError: defaultPresetOnError,
|
|
@@ -1814,7 +1926,7 @@ const PRESETS = Object.freeze({
|
|
|
1814
1926
|
retries: 20,
|
|
1815
1927
|
retryDelay: 5000,
|
|
1816
1928
|
revolvingInterval: 1024,
|
|
1817
|
-
sendingTimeout:
|
|
1929
|
+
sendingTimeout: 120_000,
|
|
1818
1930
|
mode: 'batch',
|
|
1819
1931
|
concurrency: 2,
|
|
1820
1932
|
onError: defaultPresetOnError,
|
|
@@ -1863,12 +1975,12 @@ const presetNames = Object.freeze(/** @type {MailTimePresetName[]} */ (Object.ke
|
|
|
1863
1975
|
*
|
|
1864
1976
|
* @param {MailTimePresetName} name - one of `presetNames`
|
|
1865
1977
|
* @param {object} [overrides] - additional MailTime constructor options
|
|
1866
|
-
* @returns {
|
|
1978
|
+
* @returns {MailTimePresetConfig} fresh, mutable MailTime constructor options (preset deep-cloned + overrides merged)
|
|
1867
1979
|
* @throws {Error} when `name` is unknown
|
|
1868
1980
|
* @throws {TypeError} when `overrides` is provided but not a plain object
|
|
1869
1981
|
*/
|
|
1870
1982
|
const mailTimePreset = (name, overrides) => {
|
|
1871
|
-
if (typeof name !== 'string' || !
|
|
1983
|
+
if (typeof name !== 'string' || !hasOwnProp(PRESETS, name)) {
|
|
1872
1984
|
throw new Error(`[mail-time] [mailTimePreset] unknown preset "${name}". Available: ${presetNames.join(', ')}`);
|
|
1873
1985
|
}
|
|
1874
1986
|
if (overrides !== void 0 && !isPlainObject(overrides)) {
|
|
@@ -1881,6 +1993,51 @@ const mailTimePreset = (name, overrides) => {
|
|
|
1881
1993
|
const noop = () => {};
|
|
1882
1994
|
const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
|
|
1883
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
|
+
|
|
1884
2041
|
const __withLease = (task, updateObj) => {
|
|
1885
2042
|
if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
|
|
1886
2043
|
return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
|
|
@@ -1999,7 +2156,7 @@ const buildRejectionErrorMap = (info) => {
|
|
|
1999
2156
|
return map;
|
|
2000
2157
|
};
|
|
2001
2158
|
|
|
2002
|
-
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>';
|
|
2003
2160
|
|
|
2004
2161
|
/**
|
|
2005
2162
|
* @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
|
|
@@ -2010,7 +2167,7 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
2010
2167
|
*/
|
|
2011
2168
|
|
|
2012
2169
|
/**
|
|
2013
|
-
* @typedef {{ status: string, code: number, statusCode: number, error?: unknown }} MailTimePingResult
|
|
2170
|
+
* @typedef {{ status: string, code: number, statusCode: number, paused?: boolean, error?: unknown }} MailTimePingResult
|
|
2014
2171
|
*/
|
|
2015
2172
|
|
|
2016
2173
|
/**
|
|
@@ -2030,7 +2187,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
2030
2187
|
*/
|
|
2031
2188
|
|
|
2032
2189
|
/**
|
|
2033
|
-
* @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
|
|
2034
2195
|
*/
|
|
2035
2196
|
|
|
2036
2197
|
/**
|
|
@@ -2058,7 +2219,11 @@ let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml>
|
|
|
2058
2219
|
*/
|
|
2059
2220
|
|
|
2060
2221
|
/**
|
|
2061
|
-
* @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
|
|
2062
2227
|
*/
|
|
2063
2228
|
|
|
2064
2229
|
/** Class of MailTime */
|
|
@@ -2119,8 +2284,37 @@ class MailTime {
|
|
|
2119
2284
|
this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
|
|
2120
2285
|
this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
|
|
2121
2286
|
this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
|
|
2122
|
-
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
|
+
|
|
2123
2315
|
this.__isDestroyed = false;
|
|
2316
|
+
this.__abortInFlight = false;
|
|
2317
|
+
this.__isPaused = false;
|
|
2124
2318
|
this.__readyPromise = null;
|
|
2125
2319
|
this.__schedulerTimer = null;
|
|
2126
2320
|
this.__inFlight = new Set();
|
|
@@ -2176,6 +2370,8 @@ class MailTime {
|
|
|
2176
2370
|
this.__debug(`INITIALIZING [mode: ${this.mode}]`);
|
|
2177
2371
|
this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
|
|
2178
2372
|
this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
|
|
2373
|
+
this.__debug(`INITIALIZING [renewClaim: ${this.renewClaim}]`);
|
|
2374
|
+
this.__debug(`INITIALIZING [strictPayload: ${this.strictPayload}]`);
|
|
2179
2375
|
|
|
2180
2376
|
/** SERVER-SPECIFIC CHECKS AND CONFIG */
|
|
2181
2377
|
if (this.type === 'server') {
|
|
@@ -2240,6 +2436,7 @@ class MailTime {
|
|
|
2240
2436
|
};
|
|
2241
2437
|
}
|
|
2242
2438
|
|
|
2439
|
+
/** @type {MailTimeScheduler | undefined} */
|
|
2243
2440
|
this.scheduler = new josk.JoSk({
|
|
2244
2441
|
debug: this.debug,
|
|
2245
2442
|
...this.josk,
|
|
@@ -2273,10 +2470,11 @@ class MailTime {
|
|
|
2273
2470
|
if (this.scheduler) {
|
|
2274
2471
|
const schedulerPing = await this.scheduler.ping();
|
|
2275
2472
|
if (schedulerPing.status !== 'OK') {
|
|
2276
|
-
return schedulerPing;
|
|
2473
|
+
return { ...schedulerPing, paused: this.__isPaused };
|
|
2277
2474
|
}
|
|
2278
2475
|
}
|
|
2279
|
-
|
|
2476
|
+
const queuePing = await this.queue.ping();
|
|
2477
|
+
return { ...queuePing, paused: this.__isPaused };
|
|
2280
2478
|
}
|
|
2281
2479
|
|
|
2282
2480
|
/**
|
|
@@ -2294,7 +2492,7 @@ class MailTime {
|
|
|
2294
2492
|
/**
|
|
2295
2493
|
* @memberOf MailTime
|
|
2296
2494
|
* @name destroy
|
|
2297
|
-
* @description Stop the scheduler and block future dispatches. Pass `{ drain: true }` to
|
|
2495
|
+
* @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).
|
|
2298
2496
|
* @param {{ drain?: boolean }} [opts]
|
|
2299
2497
|
* @returns {boolean | Promise<boolean>}
|
|
2300
2498
|
*/
|
|
@@ -2305,12 +2503,21 @@ class MailTime {
|
|
|
2305
2503
|
}
|
|
2306
2504
|
|
|
2307
2505
|
this.__isDestroyed = true;
|
|
2506
|
+
this.__isPaused = false;
|
|
2308
2507
|
if (this.scheduler && typeof this.scheduler.destroy === 'function') {
|
|
2309
2508
|
this.scheduler.destroy();
|
|
2310
2509
|
}
|
|
2311
2510
|
if (opts && opts.drain === true && this.__pool) {
|
|
2312
2511
|
return this.__pool.drain().then(() => true);
|
|
2313
2512
|
}
|
|
2513
|
+
// No graceful drain requested: neutralize in-flight SMTP completions so a
|
|
2514
|
+
// destroyed instance performs no storage writes, no `onSent`/`onError`
|
|
2515
|
+
// callbacks, and no logging after teardown. Each claimed row keeps its
|
|
2516
|
+
// `isSending` lock, which a peer or a future instance recovers once
|
|
2517
|
+
// `sendingTimeout` elapses — identical to a crash mid-send. Use
|
|
2518
|
+
// `destroy({ drain: true })` (or `await drain()` before `destroy()`) when
|
|
2519
|
+
// in-flight sends must run to completion.
|
|
2520
|
+
this.__abortInFlight = true;
|
|
2314
2521
|
return true;
|
|
2315
2522
|
}
|
|
2316
2523
|
|
|
@@ -2328,6 +2535,52 @@ class MailTime {
|
|
|
2328
2535
|
}
|
|
2329
2536
|
}
|
|
2330
2537
|
|
|
2538
|
+
/**
|
|
2539
|
+
* @memberOf MailTime
|
|
2540
|
+
* @name pause
|
|
2541
|
+
* @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();`.
|
|
2542
|
+
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
2543
|
+
*/
|
|
2544
|
+
pause() {
|
|
2545
|
+
this.__debug('[pause]');
|
|
2546
|
+
if (this.__isDestroyed || !this.scheduler) {
|
|
2547
|
+
return false;
|
|
2548
|
+
}
|
|
2549
|
+
const paused = this.scheduler.pause();
|
|
2550
|
+
if (paused) {
|
|
2551
|
+
this.__isPaused = true;
|
|
2552
|
+
}
|
|
2553
|
+
return paused;
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
/**
|
|
2557
|
+
* @memberOf MailTime
|
|
2558
|
+
* @name resume
|
|
2559
|
+
* @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.
|
|
2560
|
+
* @returns {boolean} `true` if newly resumed; `false` if not paused, a client instance, or destroyed
|
|
2561
|
+
*/
|
|
2562
|
+
resume() {
|
|
2563
|
+
this.__debug('[resume]');
|
|
2564
|
+
if (this.__isDestroyed || !this.scheduler) {
|
|
2565
|
+
return false;
|
|
2566
|
+
}
|
|
2567
|
+
const resumed = this.scheduler.resume();
|
|
2568
|
+
if (resumed) {
|
|
2569
|
+
this.__isPaused = false;
|
|
2570
|
+
}
|
|
2571
|
+
return resumed;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
/**
|
|
2575
|
+
* @memberOf MailTime
|
|
2576
|
+
* @name isPaused
|
|
2577
|
+
* @description Whether this instance is currently paused from draining the queue. Always `false` on `client` instances.
|
|
2578
|
+
* @returns {boolean}
|
|
2579
|
+
*/
|
|
2580
|
+
get isPaused() {
|
|
2581
|
+
return this.__isPaused;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2331
2584
|
/**
|
|
2332
2585
|
* @memberOf MailTime
|
|
2333
2586
|
* @name send
|
|
@@ -2349,12 +2602,17 @@ class MailTime {
|
|
|
2349
2602
|
* @returns {Promise<string>} uuid of the email
|
|
2350
2603
|
* @throws {Error}
|
|
2351
2604
|
*/
|
|
2352
|
-
async sendMail(opts
|
|
2605
|
+
async sendMail(opts) {
|
|
2606
|
+
opts = (opts && typeof opts === 'object') ? opts : {};
|
|
2353
2607
|
this.__debug('[sendMail]', opts);
|
|
2354
2608
|
if (!opts.html && !opts.text) {
|
|
2355
2609
|
throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
|
|
2356
2610
|
}
|
|
2357
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
|
+
|
|
2358
2616
|
let sendAt = opts.sendAt;
|
|
2359
2617
|
if (sendAt instanceof Date) {
|
|
2360
2618
|
sendAt = +sendAt;
|
|
@@ -2445,6 +2703,10 @@ class MailTime {
|
|
|
2445
2703
|
*/
|
|
2446
2704
|
async ___handleError(task, error, info) {
|
|
2447
2705
|
this.__debug('[private handleError]', { task, error, info });
|
|
2706
|
+
if (this.__abortInFlight) {
|
|
2707
|
+
this.__debug('[private handleError] instance destroyed; skipping onError', task?.uuid);
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2448
2710
|
if (!task) {
|
|
2449
2711
|
return;
|
|
2450
2712
|
}
|
|
@@ -2479,13 +2741,16 @@ class MailTime {
|
|
|
2479
2741
|
task.isSending = false;
|
|
2480
2742
|
|
|
2481
2743
|
this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
|
|
2482
|
-
this.onError
|
|
2744
|
+
callHook('onError', this.onError, error, task, info);
|
|
2483
2745
|
return;
|
|
2484
2746
|
}
|
|
2485
2747
|
|
|
2486
2748
|
let transportIndex = task.transport;
|
|
2487
2749
|
|
|
2488
|
-
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)) {
|
|
2489
2754
|
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
2490
2755
|
}
|
|
2491
2756
|
|
|
@@ -2521,7 +2786,7 @@ class MailTime {
|
|
|
2521
2786
|
this.transport = transportIndex;
|
|
2522
2787
|
}
|
|
2523
2788
|
const task = {
|
|
2524
|
-
uuid:
|
|
2789
|
+
uuid: crypto.randomUUID(),
|
|
2525
2790
|
tries: 0,
|
|
2526
2791
|
isSent: false,
|
|
2527
2792
|
sendAt: opts.sendAt,
|
|
@@ -2547,18 +2812,24 @@ class MailTime {
|
|
|
2547
2812
|
* @internal
|
|
2548
2813
|
* @memberOf MailTime
|
|
2549
2814
|
* @name ___render
|
|
2550
|
-
* @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.
|
|
2551
2821
|
* @param {string} _string - Template with Mustache-like placeholders
|
|
2552
2822
|
* @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
|
|
2823
|
+
* @param {boolean} [isHtml=true] - `false` for text/plain bodies and header values
|
|
2553
2824
|
* @returns {string}
|
|
2554
2825
|
*/
|
|
2555
|
-
___render(_string, replacements) {
|
|
2826
|
+
___render(_string, replacements, isHtml = true) {
|
|
2556
2827
|
let string = _string;
|
|
2557
2828
|
const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
|
|
2558
2829
|
if (matchHTML) {
|
|
2559
2830
|
for (let i = 0; i < matchHTML.length; i++) {
|
|
2560
2831
|
const key = matchHTML[i].slice(3, -3).trim();
|
|
2561
|
-
if (
|
|
2832
|
+
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
2562
2833
|
string = string.replace(matchHTML[i], `${replacements[key]}`);
|
|
2563
2834
|
}
|
|
2564
2835
|
}
|
|
@@ -2568,8 +2839,9 @@ class MailTime {
|
|
|
2568
2839
|
if (matchStr) {
|
|
2569
2840
|
for (let i = 0; i < matchStr.length; i++) {
|
|
2570
2841
|
const key = matchStr[i].slice(2, -2).trim();
|
|
2571
|
-
if (
|
|
2572
|
-
|
|
2842
|
+
if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
2843
|
+
const value = `${replacements[key]}`;
|
|
2844
|
+
string = string.replace(matchStr[i], isHtml ? escapeHtml(value) : value);
|
|
2573
2845
|
}
|
|
2574
2846
|
}
|
|
2575
2847
|
}
|
|
@@ -2608,7 +2880,7 @@ class MailTime {
|
|
|
2608
2880
|
const isMulti = mailOptionsList.length > 1;
|
|
2609
2881
|
|
|
2610
2882
|
for (let i = 0; i < mailOptionsList.length; i++) {
|
|
2611
|
-
const mailOption = { ...mailOptionsList[i] };
|
|
2883
|
+
const mailOption = this.___applyPayloadPolicy({ ...mailOptionsList[i] });
|
|
2612
2884
|
|
|
2613
2885
|
if (mailOption.html) {
|
|
2614
2886
|
const rendered = this.___render(mailOption.html, mailOption);
|
|
@@ -2621,7 +2893,7 @@ class MailTime {
|
|
|
2621
2893
|
}
|
|
2622
2894
|
|
|
2623
2895
|
if (mailOption.text) {
|
|
2624
|
-
const rendered = this.___render(mailOption.text, mailOption);
|
|
2896
|
+
const rendered = this.___render(mailOption.text, mailOption, false);
|
|
2625
2897
|
if (isMulti) {
|
|
2626
2898
|
compiledOpts.text += '\r\n' + rendered;
|
|
2627
2899
|
} else {
|
|
@@ -2639,11 +2911,14 @@ class MailTime {
|
|
|
2639
2911
|
|
|
2640
2912
|
if (isMulti) {
|
|
2641
2913
|
const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
|
|
2642
|
-
compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
|
|
2914
|
+
compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length }, false);
|
|
2643
2915
|
}
|
|
2644
2916
|
|
|
2645
2917
|
if (!compiledOpts.from && this.from) {
|
|
2646
|
-
compiledOpts.from = this.from(transport
|
|
2918
|
+
compiledOpts.from = this.from(transport, {
|
|
2919
|
+
index: this.transports.indexOf(transport),
|
|
2920
|
+
from: MailTime.transportFrom(transport),
|
|
2921
|
+
});
|
|
2647
2922
|
}
|
|
2648
2923
|
|
|
2649
2924
|
const acceptedSet = collectAcceptedSet(task);
|
|
@@ -2653,9 +2928,84 @@ class MailTime {
|
|
|
2653
2928
|
compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
|
|
2654
2929
|
}
|
|
2655
2930
|
|
|
2931
|
+
if (this.strictPayload) {
|
|
2932
|
+
compiledOpts.disableFileAccess = true;
|
|
2933
|
+
compiledOpts.disableUrlAccess = true;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2656
2936
|
return compiledOpts;
|
|
2657
2937
|
}
|
|
2658
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
|
+
|
|
2659
3009
|
/**
|
|
2660
3010
|
* @internal
|
|
2661
3011
|
* @memberOf MailTime
|
|
@@ -2720,6 +3070,124 @@ class MailTime {
|
|
|
2720
3070
|
}
|
|
2721
3071
|
}
|
|
2722
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
|
+
|
|
2723
3191
|
/**
|
|
2724
3192
|
* @async
|
|
2725
3193
|
* @internal
|
|
@@ -2800,127 +3268,147 @@ class MailTime {
|
|
|
2800
3268
|
const transport = this.transports[transportIndex];
|
|
2801
3269
|
|
|
2802
3270
|
const compiledOpts = this.___compileMailOpts(transport, task);
|
|
3271
|
+
const renewal = this.___startClaimRenewal(task);
|
|
2803
3272
|
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
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();
|
|
2810
3282
|
return;
|
|
2811
3283
|
}
|
|
3284
|
+
this.__debug('[private send] [sending]', { error, info });
|
|
3285
|
+
try {
|
|
3286
|
+
if (error) {
|
|
3287
|
+
await this.___handleError(task, error, info);
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
2812
3290
|
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
3291
|
+
const acceptedAddrs = Array.isArray(info?.accepted)
|
|
3292
|
+
? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
|
|
3293
|
+
: [];
|
|
2816
3294
|
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
3295
|
+
if (acceptedAddrs.length === 0) {
|
|
3296
|
+
await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
2821
3299
|
|
|
2822
|
-
|
|
3300
|
+
this.___trackAcceptedRecipients(task, acceptedAddrs);
|
|
2823
3301
|
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
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
|
+
}
|
|
2831
3310
|
}
|
|
2832
|
-
}
|
|
2833
3311
|
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
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
|
+
}
|
|
2844
3329
|
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
completed = await this.queue.update(task, leaseUpdate);
|
|
2850
|
-
}
|
|
3330
|
+
if (!completed) {
|
|
3331
|
+
this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
2851
3334
|
|
|
2852
|
-
|
|
2853
|
-
|
|
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);
|
|
2854
3340
|
return;
|
|
2855
3341
|
}
|
|
2856
3342
|
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
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
|
+
}
|
|
2867
3361
|
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
isSending: false,
|
|
2873
|
-
sendingAt: 0,
|
|
2874
|
-
mailOptions: task.mailOptions,
|
|
2875
|
-
});
|
|
3362
|
+
if (!finalized) {
|
|
3363
|
+
this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
|
|
3364
|
+
return;
|
|
3365
|
+
}
|
|
2876
3366
|
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
finalized = await this.queue.update(task, leaseUpdate);
|
|
2882
|
-
}
|
|
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;
|
|
2883
3371
|
|
|
2884
|
-
|
|
2885
|
-
|
|
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);
|
|
2886
3381
|
return;
|
|
2887
3382
|
}
|
|
2888
3383
|
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
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;
|
|
2899
3394
|
}
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
const nextSendAt = Date.now() + this.retryDelay;
|
|
2907
|
-
const released = await this.queue.update(task, __withLease(task, {
|
|
2908
|
-
isSending: false,
|
|
2909
|
-
sendingAt: 0,
|
|
2910
|
-
sendAt: nextSendAt,
|
|
2911
|
-
mailOptions: task.mailOptions,
|
|
2912
|
-
}));
|
|
2913
|
-
if (!released) {
|
|
2914
|
-
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
2915
|
-
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();
|
|
2916
3400
|
}
|
|
2917
|
-
|
|
2918
|
-
} finally {
|
|
2919
|
-
resolve();
|
|
2920
|
-
}
|
|
3401
|
+
});
|
|
2921
3402
|
});
|
|
2922
|
-
}
|
|
3403
|
+
} finally {
|
|
3404
|
+
// A transport that throws synchronously never reaches the callback above.
|
|
3405
|
+
await renewal.stop();
|
|
3406
|
+
}
|
|
2923
3407
|
} catch (e) {
|
|
3408
|
+
if (this.__abortInFlight) {
|
|
3409
|
+
this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
2924
3412
|
logError('Exception during runtime:', e);
|
|
2925
3413
|
await this.___handleError(task, e, {});
|
|
2926
3414
|
}
|
|
@@ -2935,7 +3423,7 @@ class MailTime {
|
|
|
2935
3423
|
*/
|
|
2936
3424
|
async ___iterate() {
|
|
2937
3425
|
this.__debug('[private iterate]');
|
|
2938
|
-
if (this.__isDestroyed) {
|
|
3426
|
+
if (this.__isDestroyed || this.__isPaused) {
|
|
2939
3427
|
return;
|
|
2940
3428
|
}
|
|
2941
3429
|
const limit = this.mode === 'one' ? 1 : Infinity;
|
|
@@ -3002,7 +3490,7 @@ class MailTime {
|
|
|
3002
3490
|
}
|
|
3003
3491
|
this.__unhealthyTransports.add(r.index);
|
|
3004
3492
|
logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
|
|
3005
|
-
this.onError
|
|
3493
|
+
callHook('onError', this.onError, r.error, null, { transportIndex: r.index, phase: 'verify' });
|
|
3006
3494
|
}
|
|
3007
3495
|
|
|
3008
3496
|
if (this.__unhealthyTransports.size === this.transports.length) {
|