postal-mime 2.4.6 → 2.4.7

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/.prettierignore CHANGED
@@ -6,4 +6,5 @@ coverage
6
6
  .DS_Store
7
7
  yarn.lock
8
8
  package-lock.json
9
- CLAUDE.md
9
+ CLAUDE.md
10
+ CHANGELOG.md
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.7](https://github.com/postalsys/postal-mime/compare/v2.4.6...v2.4.7) (2025-10-07)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * prevent email extraction from quoted strings in addressParser ([837d679](https://github.com/postalsys/postal-mime/commit/837d679b48cde95a111b8508a7aea23a28cbc12a))
9
+
3
10
  ## [2.4.6](https://github.com/postalsys/postal-mime/compare/v2.4.5...v2.4.6) (2025-10-01)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postal-mime",
3
- "version": "2.4.6",
3
+ "version": "2.4.7",
4
4
  "description": "Email parser for browser environments",
5
5
  "main": "./src/postal-mime.js",
6
6
  "exports": {
@@ -11,7 +11,8 @@
11
11
  "type": "module",
12
12
  "types": "postal-mime.d.ts",
13
13
  "scripts": {
14
- "test": "eslint && node --test",
14
+ "test": "npm run lint && node --test",
15
+ "lint": "eslint",
15
16
  "update": "rm -rf node_modules package-lock.json && ncu -u && npm install",
16
17
  "format": "prettier --write \"**/*.{js,mjs,json}\" --ignore-path .prettierignore",
17
18
  "format:check": "prettier --check \"**/*.{js,mjs,json}\" --ignore-path .prettierignore"
package/postal-mime.d.ts CHANGED
@@ -5,12 +5,20 @@ export type Header = {
5
5
  value: string;
6
6
  };
7
7
 
8
- export type Address = {
8
+ export type Mailbox = {
9
9
  name: string;
10
- address?: string;
11
- group?: Address[]
10
+ address: string;
11
+ group?: undefined;
12
12
  };
13
13
 
14
+ export type Address =
15
+ | Mailbox
16
+ | {
17
+ name: string;
18
+ address?: undefined;
19
+ group: Mailbox[];
20
+ };
21
+
14
22
  export type Attachment = {
15
23
  filename: string | null;
16
24
  mimeType: string;
@@ -7,7 +7,6 @@ import { decodeWords } from './decode-strings.js';
7
7
  * @return {Object} Address object
8
8
  */
9
9
  function _handleAddress(tokens) {
10
- let token;
11
10
  let isGroup = false;
12
11
  let state = 'text';
13
12
  let address;
@@ -16,28 +15,41 @@ function _handleAddress(tokens) {
16
15
  address: [],
17
16
  comment: [],
18
17
  group: [],
19
- text: []
18
+ text: [],
19
+ textWasQuoted: [] // Track which text tokens came from inside quotes
20
20
  };
21
21
  let i;
22
22
  let len;
23
+ let insideQuotes = false; // Track if we're currently inside a quoted string
23
24
 
24
25
  // Filter out <addresses>, (comments) and regular text
25
26
  for (i = 0, len = tokens.length; i < len; i++) {
26
- token = tokens[i];
27
+ let token = tokens[i];
28
+ let prevToken = i ? tokens[i - 1] : null;
27
29
  if (token.type === 'operator') {
28
30
  switch (token.value) {
29
31
  case '<':
30
32
  state = 'address';
33
+ insideQuotes = false;
31
34
  break;
32
35
  case '(':
33
36
  state = 'comment';
37
+ insideQuotes = false;
34
38
  break;
35
39
  case ':':
36
40
  state = 'group';
37
41
  isGroup = true;
42
+ insideQuotes = false;
43
+ break;
44
+ case '"':
45
+ // Track quote state for text tokens
46
+ insideQuotes = !insideQuotes;
47
+ state = 'text';
38
48
  break;
39
49
  default:
40
50
  state = 'text';
51
+ insideQuotes = false;
52
+ break;
41
53
  }
42
54
  } else if (token.value) {
43
55
  if (state === 'address') {
@@ -46,7 +58,19 @@ function _handleAddress(tokens) {
46
58
  // and so will we
47
59
  token.value = token.value.replace(/^[^<]*<\s*/, '');
48
60
  }
49
- data[state].push(token.value);
61
+
62
+ if (prevToken && prevToken.noBreak && data[state].length) {
63
+ // join values
64
+ data[state][data[state].length - 1] += token.value;
65
+ if (state === 'text' && insideQuotes) {
66
+ data.textWasQuoted[data.textWasQuoted.length - 1] = true;
67
+ }
68
+ } else {
69
+ data[state].push(token.value);
70
+ if (state === 'text') {
71
+ data.textWasQuoted.push(insideQuotes);
72
+ }
73
+ }
50
74
  }
51
75
  }
52
76
 
@@ -59,16 +83,36 @@ function _handleAddress(tokens) {
59
83
  if (isGroup) {
60
84
  // http://tools.ietf.org/html/rfc2822#appendix-A.1.3
61
85
  data.text = data.text.join(' ');
86
+
87
+ // Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting)
88
+ let groupMembers = [];
89
+ if (data.group.length) {
90
+ let parsedGroup = addressParser(data.group.join(','));
91
+ // Flatten: if any member is itself a group, extract its members into the sequence
92
+ parsedGroup.forEach(member => {
93
+ if (member.group) {
94
+ // Nested group detected - flatten it by adding its members directly
95
+ groupMembers = groupMembers.concat(member.group);
96
+ } else {
97
+ groupMembers.push(member);
98
+ }
99
+ });
100
+ }
101
+
62
102
  addresses.push({
63
103
  name: decodeWords(data.text || (address && address.name)),
64
- group: data.group.length ? addressParser(data.group.join(',')) : []
104
+ group: groupMembers
65
105
  });
66
106
  } else {
67
107
  // If no address was found, try to detect one from regular text
68
108
  if (!data.address.length && data.text.length) {
69
109
  for (i = data.text.length - 1; i >= 0; i--) {
70
- if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
110
+ // Security fix: Do not extract email addresses from quoted strings
111
+ // RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com
112
+ // Extracting emails from quoted text leads to misrouting vulnerabilities
113
+ if (!data.textWasQuoted[i] && data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
71
114
  data.address = data.text.splice(i, 1);
115
+ data.textWasQuoted.splice(i, 1);
72
116
  break;
73
117
  }
74
118
  }
@@ -85,10 +129,13 @@ function _handleAddress(tokens) {
85
129
  // still no address
86
130
  if (!data.address.length) {
87
131
  for (i = data.text.length - 1; i >= 0; i--) {
88
- // fixed the regex to parse email address correctly when email address has more than one @
89
- data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
90
- if (data.address.length) {
91
- break;
132
+ // Security fix: Do not extract email addresses from quoted strings
133
+ if (!data.textWasQuoted[i]) {
134
+ // fixed the regex to parse email address correctly when email address has more than one @
135
+ data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
136
+ if (data.address.length) {
137
+ break;
138
+ }
92
139
  }
93
140
  }
94
141
  }
@@ -180,11 +227,12 @@ class Tokenizer {
180
227
  * @return {Array} An array of operator|text tokens
181
228
  */
182
229
  tokenize() {
183
- let chr,
184
- list = [];
230
+ let list = [];
231
+
185
232
  for (let i = 0, len = this.str.length; i < len; i++) {
186
- chr = this.str.charAt(i);
187
- this.checkChar(chr);
233
+ let chr = this.str.charAt(i);
234
+ let nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
235
+ this.checkChar(chr, nextChr);
188
236
  }
189
237
 
190
238
  this.list.forEach(node => {
@@ -202,7 +250,7 @@ class Tokenizer {
202
250
  *
203
251
  * @param {String} chr Character from the address field
204
252
  */
205
- checkChar(chr) {
253
+ checkChar(chr, nextChr) {
206
254
  if (this.escaped) {
207
255
  // ignore next condition blocks
208
256
  } else if (chr === this.operatorExpecting) {
@@ -210,10 +258,16 @@ class Tokenizer {
210
258
  type: 'operator',
211
259
  value: chr
212
260
  };
261
+
262
+ if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
263
+ this.node.noBreak = true;
264
+ }
265
+
213
266
  this.list.push(this.node);
214
267
  this.node = null;
215
268
  this.operatorExpecting = '';
216
269
  this.escaped = false;
270
+
217
271
  return;
218
272
  } else if (!this.operatorExpecting && chr in this.operators) {
219
273
  this.node = {