mail-time 5.2.1 → 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.
@@ -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
- const ok = await operation(guard);
49
- if (!ok) this.__halt();
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
- this.__halt();
53
- if (!this.__shouldAbort()) this.__report(error);
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() {
@@ -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
- const normalizePolicyAddress = (value) => {
7
- const input = typeof value === 'string' ? value : (!Array.isArray(value) && value?.address);
8
- if (typeof input !== 'string' || /[\r\n]/u.test(input)) {
9
- throw policyError('recipient requires a simple address without line breaks');
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
- let address = input.trim();
12
- if (address.includes('<') || address.includes('>')) {
13
- const match = address.match(/^[^<>,;:"\r\n]*<([^<>]+)>$/u);
14
- if (!match) throw policyError('use a simple explicit envelope.to');
15
- address = match[1].trim();
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
- if (!SIMPLE_MAILBOX.test(address)) throw policyError('use a simple explicit envelope.to');
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
- for (const entry of Array.isArray(value) ? value : [value]) {
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(entry); }
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
- const from = compiled.from || compiled.sender || compiled.replyTo;
50
- if (from) envelope.from = normalizePolicyAddress(from);
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 };