mail-time 5.2.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -11
- package/adapters/mongo.js +1 -1
- package/adapters/postgres.js +3 -0
- package/adapters/redis.js +6 -0
- package/index.cjs +556 -141
- package/index.d.cts +21 -77
- package/index.d.ts +21 -77
- package/index.js +342 -99
- package/package.json +5 -4
- package/presets.js +15 -15
- package/recipient-policy-lease.js +40 -8
- package/recipient-policy.js +151 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mail-time",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.0",
|
|
4
4
|
"description": "📮 Email queue extending nodemailer with multi-SMTP transports and horizontally scaled apps support",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
"author": "dr.dimitru (https://veliovgroup.com)",
|
|
135
135
|
"license": "BSD-3-Clause",
|
|
136
136
|
"engines": {
|
|
137
|
-
"node": ">=
|
|
137
|
+
"node": ">=14.19.3",
|
|
138
138
|
"bun": ">=1.1.0"
|
|
139
139
|
},
|
|
140
140
|
"meteor": {
|
|
@@ -149,16 +149,17 @@
|
|
|
149
149
|
}
|
|
150
150
|
},
|
|
151
151
|
"dependencies": {
|
|
152
|
-
"josk": "^6.
|
|
152
|
+
"josk": "^6.5.0"
|
|
153
153
|
},
|
|
154
154
|
"devDependencies": {
|
|
155
155
|
"@jest/globals": "^30.4.1",
|
|
156
156
|
"@types/node": "^25.9.0",
|
|
157
|
+
"acorn": "^8.18.0",
|
|
157
158
|
"chai": "^6.2.2",
|
|
158
159
|
"jest": "^30.4.2",
|
|
159
160
|
"mocha": "^11.8.0",
|
|
160
161
|
"mongodb": "^7.2.0",
|
|
161
|
-
"nodemailer": "^
|
|
162
|
+
"nodemailer": "^10.0.9",
|
|
162
163
|
"pg": "^8.21.0",
|
|
163
164
|
"redis": "^5.12.1",
|
|
164
165
|
"rollup": "^4.60.4",
|
package/presets.js
CHANGED
|
@@ -60,12 +60,12 @@ const PRESETS = Object.freeze({
|
|
|
60
60
|
transactional: Object.freeze({
|
|
61
61
|
concatEmails: false,
|
|
62
62
|
retries: 30,
|
|
63
|
-
retryDelay:
|
|
63
|
+
retryDelay: 10000,
|
|
64
64
|
mode: 'batch',
|
|
65
65
|
concurrency: 1,
|
|
66
66
|
onError: defaultPresetOnError,
|
|
67
67
|
josk: Object.freeze({
|
|
68
|
-
zombieTime:
|
|
68
|
+
zombieTime: 120000,
|
|
69
69
|
}),
|
|
70
70
|
}),
|
|
71
71
|
otp: Object.freeze({
|
|
@@ -73,52 +73,52 @@ const PRESETS = Object.freeze({
|
|
|
73
73
|
retries: 5,
|
|
74
74
|
retryDelay: 2000,
|
|
75
75
|
revolvingInterval: 1024,
|
|
76
|
-
sendingTimeout:
|
|
76
|
+
sendingTimeout: 120000,
|
|
77
77
|
mode: 'batch',
|
|
78
78
|
concurrency: 4,
|
|
79
79
|
onError: defaultPresetOnError,
|
|
80
80
|
josk: Object.freeze({
|
|
81
81
|
minRevolvingDelay: 256,
|
|
82
82
|
maxRevolvingDelay: 1024,
|
|
83
|
-
zombieTime:
|
|
83
|
+
zombieTime: 60000,
|
|
84
84
|
}),
|
|
85
85
|
}),
|
|
86
86
|
newsletter: Object.freeze({
|
|
87
87
|
concatEmails: true,
|
|
88
|
-
concatDelay: 5 *
|
|
88
|
+
concatDelay: 5 * 60000,
|
|
89
89
|
concatSubject: 'Your updates',
|
|
90
90
|
retries: 5,
|
|
91
|
-
retryDelay:
|
|
92
|
-
sendingTimeout:
|
|
91
|
+
retryDelay: 60000,
|
|
92
|
+
sendingTimeout: 600000,
|
|
93
93
|
mode: 'batch',
|
|
94
94
|
concurrency: 2,
|
|
95
95
|
onError: defaultPresetOnError,
|
|
96
96
|
josk: Object.freeze({
|
|
97
|
-
zombieTime:
|
|
97
|
+
zombieTime: 300000,
|
|
98
98
|
}),
|
|
99
99
|
}),
|
|
100
100
|
marketing: Object.freeze({
|
|
101
101
|
concatEmails: false,
|
|
102
102
|
retries: 10,
|
|
103
|
-
retryDelay:
|
|
103
|
+
retryDelay: 30000,
|
|
104
104
|
mode: 'batch',
|
|
105
105
|
concurrency: 5,
|
|
106
106
|
onError: defaultPresetOnError,
|
|
107
107
|
josk: Object.freeze({
|
|
108
|
-
zombieTime:
|
|
108
|
+
zombieTime: 180000,
|
|
109
109
|
}),
|
|
110
110
|
}),
|
|
111
111
|
notifications: Object.freeze({
|
|
112
112
|
concatEmails: true,
|
|
113
|
-
concatDelay:
|
|
113
|
+
concatDelay: 60000,
|
|
114
114
|
concatSubject: 'New activity',
|
|
115
115
|
retries: 8,
|
|
116
|
-
retryDelay:
|
|
116
|
+
retryDelay: 30000,
|
|
117
117
|
mode: 'batch',
|
|
118
118
|
concurrency: 3,
|
|
119
119
|
onError: defaultPresetOnError,
|
|
120
120
|
josk: Object.freeze({
|
|
121
|
-
zombieTime:
|
|
121
|
+
zombieTime: 180000,
|
|
122
122
|
}),
|
|
123
123
|
}),
|
|
124
124
|
alerts: Object.freeze({
|
|
@@ -126,14 +126,14 @@ const PRESETS = Object.freeze({
|
|
|
126
126
|
retries: 20,
|
|
127
127
|
retryDelay: 5000,
|
|
128
128
|
revolvingInterval: 1024,
|
|
129
|
-
sendingTimeout:
|
|
129
|
+
sendingTimeout: 120000,
|
|
130
130
|
mode: 'batch',
|
|
131
131
|
concurrency: 2,
|
|
132
132
|
onError: defaultPresetOnError,
|
|
133
133
|
josk: Object.freeze({
|
|
134
134
|
minRevolvingDelay: 256,
|
|
135
135
|
maxRevolvingDelay: 1024,
|
|
136
|
-
zombieTime:
|
|
136
|
+
zombieTime: 60000,
|
|
137
137
|
}),
|
|
138
138
|
}),
|
|
139
139
|
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
class RecipientPolicyLease {
|
|
2
|
-
constructor({ task, queue, interval, maxRenewals, shouldAbort, report }) {
|
|
2
|
+
constructor({ task, queue, interval, maxRenewals, sendingTimeout, shouldAbort, report }) {
|
|
3
|
+
if (typeof sendingTimeout !== 'number' || !(sendingTimeout > 0)) throw new TypeError('[mail-time] [recipientPolicies] RecipientPolicyLease requires a positive sendingTimeout');
|
|
4
|
+
this.__sendingTimeout = sendingTimeout;
|
|
5
|
+
this.__renewStale = false;
|
|
6
|
+
this.__liveAtAttempt = false;
|
|
3
7
|
this.__task = task;
|
|
4
8
|
this.__queue = queue;
|
|
5
9
|
this.__shouldAbort = shouldAbort;
|
|
@@ -7,6 +11,8 @@ class RecipientPolicyLease {
|
|
|
7
11
|
this.__closed = false;
|
|
8
12
|
this.__finishing = false;
|
|
9
13
|
this.__tail = Promise.resolve();
|
|
14
|
+
this.__renewUncertain = false;
|
|
15
|
+
this.__attemptedAt = 0;
|
|
10
16
|
this.__timer = null;
|
|
11
17
|
let pending = false;
|
|
12
18
|
let renewals = 0;
|
|
@@ -21,6 +27,8 @@ class RecipientPolicyLease {
|
|
|
21
27
|
renewals++;
|
|
22
28
|
this.__enqueue(async (guard) => {
|
|
23
29
|
const fields = { isSending: true, sendingAt: Math.max(Date.now(), this.__task.sendingAt + 1) };
|
|
30
|
+
this.__attemptedAt = fields.sendingAt;
|
|
31
|
+
this.__liveAtAttempt = this.__task.sendingAt > fields.sendingAt - this.__sendingTimeout;
|
|
24
32
|
return await this.__write(fields, guard);
|
|
25
33
|
}).finally(() => { pending = false; });
|
|
26
34
|
}, interval);
|
|
@@ -40,17 +48,41 @@ class RecipientPolicyLease {
|
|
|
40
48
|
this.__closed = true;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
|
-
__enqueue(operation) {
|
|
51
|
+
__enqueue(operation, phase = 'renew') {
|
|
44
52
|
const pending = this.__tail.then(async () => {
|
|
45
53
|
if (!this.active) return false;
|
|
46
54
|
const guard = { leaseTries: this.__task.tries, leaseSendingAt: this.__task.sendingAt };
|
|
47
55
|
try {
|
|
48
|
-
|
|
49
|
-
if (!ok)
|
|
56
|
+
let ok = await operation(guard);
|
|
57
|
+
if (!ok) {
|
|
58
|
+
// A renewal write that threw may have been applied by storage, which leaves the
|
|
59
|
+
// guard stale. Retry once with the attempted stamp: a peer takeover bumps tries or
|
|
60
|
+
// stamps a later sendingAt while the lease is live, so the retry cannot match a
|
|
61
|
+
// taken-over row. Report if it still fails.
|
|
62
|
+
if (this.__renewStale && phase !== 'renew') {
|
|
63
|
+
// The claim was already stale when the renewal threw: no retry, report the loss.
|
|
64
|
+
this.__renewStale = false;
|
|
65
|
+
this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
|
|
66
|
+
} else if (this.__renewUncertain && phase !== 'renew') {
|
|
67
|
+
this.__renewUncertain = false;
|
|
68
|
+
if (this.__shouldAbort()) {
|
|
69
|
+
this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
|
|
70
|
+
this.__halt();
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
ok = await operation({ ...guard, leaseSendingAt: this.__attemptedAt });
|
|
74
|
+
if (ok && phase === 'checkpoint') this.__task.sendingAt = this.__attemptedAt;
|
|
75
|
+
if (!ok) this.__report(new Error('outcome write lost (renewal outcome uncertain or lease taken over)'), phase);
|
|
76
|
+
}
|
|
77
|
+
if (!ok) this.__halt();
|
|
78
|
+
}
|
|
50
79
|
return ok;
|
|
51
80
|
} catch (error) {
|
|
52
|
-
|
|
53
|
-
|
|
81
|
+
// A thrown renewal write only stops further renewals. The lease stays open so
|
|
82
|
+
// finish() still records the outcome; storage-side CAS protects row ownership.
|
|
83
|
+
if (phase === 'renew') { this.__clearTimer(); this.__renewUncertain = this.__liveAtAttempt; this.__renewStale = !this.__liveAtAttempt; }
|
|
84
|
+
else this.__halt();
|
|
85
|
+
this.__report(error, phase);
|
|
54
86
|
return false;
|
|
55
87
|
}
|
|
56
88
|
});
|
|
@@ -66,7 +98,7 @@ class RecipientPolicyLease {
|
|
|
66
98
|
|
|
67
99
|
update(fields) {
|
|
68
100
|
if (this.__finishing) return Promise.resolve(false);
|
|
69
|
-
return this.__enqueue((guard) => this.__write(fields, guard));
|
|
101
|
+
return this.__enqueue((guard) => this.__write(fields, guard), 'checkpoint');
|
|
70
102
|
}
|
|
71
103
|
|
|
72
104
|
finish(fields, remove) {
|
|
@@ -78,7 +110,7 @@ class RecipientPolicyLease {
|
|
|
78
110
|
if (ok) Object.assign(this.__task, fields);
|
|
79
111
|
this.__halt();
|
|
80
112
|
return ok;
|
|
81
|
-
});
|
|
113
|
+
}, 'complete');
|
|
82
114
|
}
|
|
83
115
|
|
|
84
116
|
async stop() {
|
package/recipient-policy.js
CHANGED
|
@@ -2,29 +2,113 @@ import { hasOwnProp, isPlainObject } from './helpers.js';
|
|
|
2
2
|
|
|
3
3
|
const policyError = (message) => new Error(`[mail-time] [recipientPolicies] ${message}`);
|
|
4
4
|
const SIMPLE_MAILBOX = /^[^\s<>,;:"()\\\[\]@]+@[^\s<>,;:"()\\\[\]@]+$/u;
|
|
5
|
+
// Unquoted display-name characters that are auto-quoted in the sent header; `,` `;` `:` `<` `>` stay rejected.
|
|
6
|
+
const QUOTABLE_SPECIALS = /[()[\]@\\]/u;
|
|
7
|
+
const UNSAFE_CHARS = /[\r\n\u0000]/u;
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Error for an address that cannot be parsed into exactly one mailbox. The message names
|
|
11
|
+
* the field and the rule that failed, never the address or display name itself.
|
|
12
|
+
*/
|
|
13
|
+
const addressError = (field, reason) => {
|
|
14
|
+
const error = policyError(`\`${field}\` ${reason}`);
|
|
15
|
+
error.code = 'MAIL_TIME_INVALID_ADDRESS';
|
|
16
|
+
error.field = field;
|
|
17
|
+
return error;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const isAddressError = (error) => error?.code === 'MAIL_TIME_INVALID_ADDRESS';
|
|
21
|
+
|
|
22
|
+
const toMailbox = (address, field) => {
|
|
23
|
+
const trimmed = address.trim();
|
|
24
|
+
if (!SIMPLE_MAILBOX.test(trimmed)) throw addressError(field, 'must contain one address such as user@example.com; quoted local parts are not supported');
|
|
25
|
+
return trimmed.toLowerCase();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse one Nodemailer mailbox string: `addr`, `<addr>`, or `display name <addr>`, where the
|
|
30
|
+
* display name is any mix of atoms and quoted strings with backslash escapes. Unquoted
|
|
31
|
+
* `( ) [ ] @ \\` in a display name set `quote`, so the sent header carries the name quoted.
|
|
32
|
+
* Groups, comma-separated lists, and text after `>` are rejected rather than guessed at.
|
|
33
|
+
* @returns {{ name: string, address: string, quote: boolean }} `address` keeps its case.
|
|
34
|
+
*/
|
|
35
|
+
const parseMailboxString = (input, field) => {
|
|
36
|
+
let display = '';
|
|
37
|
+
let name = '';
|
|
38
|
+
let angle = null;
|
|
39
|
+
let quoted = false;
|
|
40
|
+
for (let i = 0; i < input.length; i++) {
|
|
41
|
+
const char = input[i];
|
|
42
|
+
if (quoted) {
|
|
43
|
+
if (char === '\\') {
|
|
44
|
+
if (++i >= input.length) break;
|
|
45
|
+
name += input[i];
|
|
46
|
+
} else if (char === '"') {
|
|
47
|
+
quoted = false;
|
|
48
|
+
} else {
|
|
49
|
+
name += char;
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (char === '"') {
|
|
54
|
+
quoted = true;
|
|
55
|
+
} else if (char === '<') {
|
|
56
|
+
const close = input.indexOf('>', i + 1);
|
|
57
|
+
if (close === -1) throw addressError(field, 'has an unclosed angle bracket');
|
|
58
|
+
angle = input.slice(i + 1, close);
|
|
59
|
+
if (input.slice(close + 1).trim()) throw addressError(field, 'must end after the angle-bracket address; use an array for multiple recipients');
|
|
60
|
+
break;
|
|
61
|
+
} else if (char === ',') {
|
|
62
|
+
throw addressError(field, 'must contain one mailbox; use an array for multiple recipients or quote a display name that contains a comma');
|
|
63
|
+
} else if (char === ';' || char === ':') {
|
|
64
|
+
throw addressError(field, 'must not use group syntax');
|
|
65
|
+
} else if (char === '>') {
|
|
66
|
+
throw addressError(field, 'has an unquoted `>` in its display name; wrap the name in double quotes');
|
|
67
|
+
} else {
|
|
68
|
+
display += char;
|
|
69
|
+
name += char;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (quoted) throw addressError(field, 'has an unterminated quoted display name');
|
|
73
|
+
if (angle === null) {
|
|
74
|
+
if (display !== input) throw addressError(field, 'must contain one address such as user@example.com; quoted local parts are not supported');
|
|
75
|
+
toMailbox(input, field);
|
|
76
|
+
return { name: '', address: input, quote: false };
|
|
77
|
+
}
|
|
78
|
+
toMailbox(angle, field);
|
|
79
|
+
return { name: name.replace(/\s+/gu, ' ').trim(), address: angle.trim(), quote: QUOTABLE_SPECIALS.test(display) };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalize exactly one mailbox (string or `{ name?, address }`) to its lowercase address.
|
|
84
|
+
* @param {unknown} value
|
|
85
|
+
* @param {string} [field] - Field label used in errors, e.g. `to[1]` or `envelope.from`.
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
const normalizePolicyAddress = (value, field = 'address') => {
|
|
89
|
+
if (typeof value === 'string') {
|
|
90
|
+
if (UNSAFE_CHARS.test(value)) throw addressError(field, 'must not contain line breaks or NUL characters');
|
|
91
|
+
if (!value.trim()) throw addressError(field, 'must not be empty');
|
|
92
|
+
return parseMailboxString(value.trim(), field).address.toLowerCase();
|
|
10
93
|
}
|
|
11
|
-
|
|
12
|
-
if (address
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
94
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw addressError(field, 'must be an address string or a { name, address } object');
|
|
95
|
+
if (typeof value.address !== 'string') throw addressError(field, 'object requires a string `address`');
|
|
96
|
+
if (value.name !== void 0 && typeof value.name !== 'string') throw addressError(field, 'object `name` must be a string');
|
|
97
|
+
if (UNSAFE_CHARS.test(value.address) || (value.name && UNSAFE_CHARS.test(value.name))) {
|
|
98
|
+
throw addressError(field, 'must not contain line breaks or NUL characters');
|
|
16
99
|
}
|
|
17
|
-
|
|
18
|
-
return address.toLowerCase();
|
|
100
|
+
return toMailbox(value.address, field);
|
|
19
101
|
};
|
|
20
102
|
|
|
21
103
|
const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
22
104
|
const recipients = new Map();
|
|
23
105
|
const explicit = compiled.envelope && hasOwnProp(compiled.envelope, 'to');
|
|
24
|
-
const add = (value, source, authoritative) => {
|
|
25
|
-
|
|
106
|
+
const add = (value, source, authoritative, label = source) => {
|
|
107
|
+
const list = Array.isArray(value);
|
|
108
|
+
const entries = list ? value : [value];
|
|
109
|
+
for (let i = 0; i < entries.length; i++) {
|
|
26
110
|
let address;
|
|
27
|
-
try { address = normalizePolicyAddress(
|
|
111
|
+
try { address = normalizePolicyAddress(entries[i], list ? `${label}[${i}]` : label); }
|
|
28
112
|
catch (error) { if (authoritative) throw error; else continue; }
|
|
29
113
|
if (!recipients.has(address)) {
|
|
30
114
|
if (!authoritative) continue;
|
|
@@ -34,7 +118,7 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
|
34
118
|
if (!sources.includes(source)) sources.push(source);
|
|
35
119
|
}
|
|
36
120
|
};
|
|
37
|
-
if (explicit) add(compiled.envelope.to, 'envelope', true);
|
|
121
|
+
if (explicit) add(compiled.envelope.to, 'envelope', true, 'envelope.to');
|
|
38
122
|
for (const source of ['to', 'cc', 'bcc']) {
|
|
39
123
|
if (hasOwnProp(compiled, source) && compiled[source] !== void 0) add(compiled[source], source, !explicit);
|
|
40
124
|
}
|
|
@@ -44,14 +128,61 @@ const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
|
44
128
|
}
|
|
45
129
|
const envelope = { to: [...recipients.keys()] };
|
|
46
130
|
if (compiled.envelope && hasOwnProp(compiled.envelope, 'from')) {
|
|
47
|
-
envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from);
|
|
131
|
+
envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from, 'envelope.from');
|
|
48
132
|
} else {
|
|
49
|
-
|
|
50
|
-
|
|
133
|
+
// Same precedence as Nodemailer's getEnvelope(): From, then Sender, then Reply-To.
|
|
134
|
+
const field = ['from', 'sender', 'replyTo'].find((key) => compiled[key]);
|
|
135
|
+
if (field) envelope.from = normalizePolicyAddress(compiled[field], field);
|
|
51
136
|
}
|
|
52
137
|
return { envelope, recipients: [...recipients.values()] };
|
|
53
138
|
};
|
|
54
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Return `compiled` with every header mailbox string whose display name needs quoting replaced by
|
|
142
|
+
* `{ name, address }`, so Nodemailer renders the same mailbox the policy checked. Other entries,
|
|
143
|
+
* including ones that do not parse, are left as they are. The input is never mutated.
|
|
144
|
+
*/
|
|
145
|
+
const rewritePolicyHeaders = (compiled) => {
|
|
146
|
+
let result = compiled;
|
|
147
|
+
const rewrite = (value) => {
|
|
148
|
+
if (typeof value !== 'string' || UNSAFE_CHARS.test(value)) return value;
|
|
149
|
+
try {
|
|
150
|
+
const { name, address, quote } = parseMailboxString(value.trim(), 'header');
|
|
151
|
+
return quote ? { name, address } : value;
|
|
152
|
+
} catch { return value; }
|
|
153
|
+
};
|
|
154
|
+
for (const key of ['from', 'sender', 'replyTo', 'to', 'cc', 'bcc']) {
|
|
155
|
+
if (!hasOwnProp(compiled, key)) continue;
|
|
156
|
+
const value = compiled[key];
|
|
157
|
+
const next = Array.isArray(value) ? value.map(rewrite) : rewrite(value);
|
|
158
|
+
if (Array.isArray(value) ? next.some((entry, i) => entry !== value[i]) : next !== value) {
|
|
159
|
+
if (result === compiled) result = { ...compiled };
|
|
160
|
+
result[key] = next;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Validate every address field present in a letter with the policy parser, so an unparseable
|
|
168
|
+
* address is rejected at `sendMail()` instead of after a queue round trip. Absent fields are
|
|
169
|
+
* skipped. Throws the same `MAIL_TIME_INVALID_ADDRESS` error the send path would.
|
|
170
|
+
* @param {object} mailOptions
|
|
171
|
+
* @returns {void}
|
|
172
|
+
*/
|
|
173
|
+
const validatePolicyMailOptions = (mailOptions) => {
|
|
174
|
+
const check = (value, label) => {
|
|
175
|
+
if (value === void 0) return;
|
|
176
|
+
if (Array.isArray(value)) value.forEach((entry, i) => normalizePolicyAddress(entry, `${label}[${i}]`));
|
|
177
|
+
else normalizePolicyAddress(value, label);
|
|
178
|
+
};
|
|
179
|
+
for (const key of ['from', 'sender', 'replyTo', 'to', 'cc', 'bcc']) check(mailOptions[key], key);
|
|
180
|
+
if (isPlainObject(mailOptions.envelope)) {
|
|
181
|
+
check(mailOptions.envelope.to, 'envelope.to');
|
|
182
|
+
if (mailOptions.envelope.from !== '') check(mailOptions.envelope.from, 'envelope.from');
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
55
186
|
const validateRecipientPolicies = (value, queue) => {
|
|
56
187
|
if (value === void 0) return null;
|
|
57
188
|
if (!Array.isArray(value) || !value.length) throw policyError('recipientPolicies must be a nonempty array');
|
|
@@ -88,7 +219,7 @@ const validatePolicyResult = (raw, hook, context) => {
|
|
|
88
219
|
const decisions = new Map();
|
|
89
220
|
for (const item of raw.decisions) {
|
|
90
221
|
if (!isPlainObject(item)) throw policyError('decision must be an object');
|
|
91
|
-
const address = normalizePolicyAddress(item.address);
|
|
222
|
+
const address = normalizePolicyAddress(item.address, 'decision.address');
|
|
92
223
|
if (!batch.has(address)) throw policyError('decision address is outside the input batch');
|
|
93
224
|
const allowed = hook === 'beforeSend' ? item.status === 'suppressed' : (item.status === 'retry' || item.status === 'rejected');
|
|
94
225
|
if (!allowed) throw policyError('invalid decision status for this phase');
|
|
@@ -163,4 +294,4 @@ const summarizePolicyTask = (task, isSettled) => {
|
|
|
163
294
|
return { uuid: task.uuid, tries: task.tries, isSettled, recipients };
|
|
164
295
|
};
|
|
165
296
|
|
|
166
|
-
export { policyError, normalizePolicyAddress, preparePolicyEnvelope, validateRecipientPolicies, evaluatePolicyPhase, mergePolicyResults, summarizePolicyTask };
|
|
297
|
+
export { policyError, isAddressError, normalizePolicyAddress, rewritePolicyHeaders, preparePolicyEnvelope, validatePolicyMailOptions, validateRecipientPolicies, evaluatePolicyPhase, mergePolicyResults, summarizePolicyTask };
|