postal-mime 2.6.0 → 2.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.7.0](https://github.com/postalsys/postal-mime/compare/v2.6.1...v2.7.0) (2025-12-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * add headerLines property exposing raw header lines ([c79a02a](https://github.com/postalsys/postal-mime/commit/c79a02ab05d9cac44e05e95a433752ff292aa5eb))
9
+
10
+ ## [2.6.1](https://github.com/postalsys/postal-mime/compare/v2.6.0...v2.6.1) (2025-11-26)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * prevent DoS from deeply nested address groups ([f509eaf](https://github.com/postalsys/postal-mime/commit/f509eafec31bf448482133041c086f7aefa2b3fa))
16
+ * update TypeScript typings to match source code ([df5640a](https://github.com/postalsys/postal-mime/commit/df5640a3166b0a1fe3ca563e1364301bb4e6a025))
17
+
3
18
  ## [2.6.0](https://github.com/postalsys/postal-mime/compare/v2.5.0...v2.6.0) (2025-10-24)
4
19
 
5
20
 
package/README.md CHANGED
@@ -39,7 +39,7 @@ The source code is available on [GitHub](https://github.com/postalsys/postal-mim
39
39
 
40
40
  ## Demo
41
41
 
42
- Try out a live demo using the [example page](https://kreata.ee/postal-mime/example/).
42
+ Try out a live demo using the [example page](https://postal-mime.postalsys.com/demo).
43
43
 
44
44
  ## Installation
45
45
 
@@ -22,7 +22,7 @@ __export(address_parser_exports, {
22
22
  });
23
23
  module.exports = __toCommonJS(address_parser_exports);
24
24
  var import_decode_strings = require("./decode-strings.cjs");
25
- function _handleAddress(tokens) {
25
+ function _handleAddress(tokens, depth) {
26
26
  let isGroup = false;
27
27
  let state = "text";
28
28
  let address;
@@ -90,7 +90,7 @@ function _handleAddress(tokens) {
90
90
  data.text = data.text.join(" ");
91
91
  let groupMembers = [];
92
92
  if (data.group.length) {
93
- let parsedGroup = addressParser(data.group.join(","));
93
+ let parsedGroup = addressParser(data.group.join(","), { _depth: depth + 1 });
94
94
  parsedGroup.forEach((member) => {
95
95
  if (member.group) {
96
96
  groupMembers = groupMembers.concat(member.group);
@@ -258,8 +258,13 @@ class Tokenizer {
258
258
  this.escaped = false;
259
259
  }
260
260
  }
261
+ const MAX_NESTED_GROUP_DEPTH = 50;
261
262
  function addressParser(str, options) {
262
263
  options = options || {};
264
+ let depth = options._depth || 0;
265
+ if (depth > MAX_NESTED_GROUP_DEPTH) {
266
+ return [];
267
+ }
263
268
  let tokenizer = new Tokenizer(str);
264
269
  let tokens = tokenizer.tokenize();
265
270
  let addresses = [];
@@ -279,7 +284,7 @@ function addressParser(str, options) {
279
284
  addresses.push(address);
280
285
  }
281
286
  addresses.forEach((address2) => {
282
- address2 = _handleAddress(address2);
287
+ address2 = _handleAddress(address2, depth);
283
288
  if (address2.length) {
284
289
  parsedAddresses = parsedAddresses.concat(address2);
285
290
  }
@@ -193,31 +193,40 @@ class MimeNode {
193
193
  if (i && /^\s/.test(line)) {
194
194
  this.headerLines[i - 1] += "\n" + line;
195
195
  this.headerLines.splice(i, 1);
196
- } else {
197
- line = line.replace(/\s+/g, " ");
198
- let sep = line.indexOf(":");
199
- let key = sep < 0 ? line.trim() : line.substr(0, sep).trim();
200
- let value = sep < 0 ? "" : line.substr(sep + 1).trim();
201
- this.headers.push({ key: key.toLowerCase(), originalKey: key, value });
202
- switch (key.toLowerCase()) {
203
- case "content-type":
204
- if (this.contentType.default) {
205
- this.contentType = { value, parsed: {} };
206
- }
207
- break;
208
- case "content-transfer-encoding":
209
- this.contentTransferEncoding = { value, parsed: {} };
210
- break;
211
- case "content-disposition":
212
- this.contentDisposition = { value, parsed: {} };
213
- break;
214
- case "content-id":
215
- this.contentId = value;
216
- break;
217
- case "content-description":
218
- this.contentDescription = value;
219
- break;
220
- }
196
+ }
197
+ }
198
+ this.rawHeaderLines = [];
199
+ for (let i = this.headerLines.length - 1; i >= 0; i--) {
200
+ let rawLine = this.headerLines[i];
201
+ let sep = rawLine.indexOf(":");
202
+ let rawKey = sep < 0 ? rawLine.trim() : rawLine.substr(0, sep).trim();
203
+ this.rawHeaderLines.push({
204
+ key: rawKey.toLowerCase(),
205
+ line: rawLine
206
+ });
207
+ let normalizedLine = rawLine.replace(/\s+/g, " ");
208
+ sep = normalizedLine.indexOf(":");
209
+ let key = sep < 0 ? normalizedLine.trim() : normalizedLine.substr(0, sep).trim();
210
+ let value = sep < 0 ? "" : normalizedLine.substr(sep + 1).trim();
211
+ this.headers.push({ key: key.toLowerCase(), originalKey: key, value });
212
+ switch (key.toLowerCase()) {
213
+ case "content-type":
214
+ if (this.contentType.default) {
215
+ this.contentType = { value, parsed: {} };
216
+ }
217
+ break;
218
+ case "content-transfer-encoding":
219
+ this.contentTransferEncoding = { value, parsed: {} };
220
+ break;
221
+ case "content-disposition":
222
+ this.contentDisposition = { value, parsed: {} };
223
+ break;
224
+ case "content-id":
225
+ this.contentId = value;
226
+ break;
227
+ case "content-description":
228
+ this.contentDescription = value;
229
+ break;
221
230
  }
222
231
  }
223
232
  this.contentType.parsed = this.parseStructuredHeader(this.contentType.value);
@@ -426,6 +426,7 @@ class PostalMime {
426
426
  message.text = this.textContent.plain;
427
427
  }
428
428
  message.attachments = this.attachments;
429
+ message.headerLines = (this.root.rawHeaderLines || []).slice().reverse();
429
430
  switch (this.attachmentEncoding) {
430
431
  case "arraybuffer":
431
432
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postal-mime",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Email parser for browser environments",
5
5
  "main": "./dist/postal-mime.cjs",
6
6
  "module": "./src/postal-mime.js",
@@ -34,17 +34,18 @@
34
34
  "bugs": {
35
35
  "url": "https://github.com/postalsys/postal-mime/issues"
36
36
  },
37
+ "homepage": "https://postal-mime.postalsys.com",
37
38
  "author": "Andris Reinman",
38
39
  "license": "MIT-0",
39
40
  "devDependencies": {
40
- "@types/node": "24.0.4",
41
+ "@types/node": "24.10.1",
41
42
  "cross-blob": "3.0.2",
42
- "cross-env": "7.0.3",
43
- "esbuild": "^0.25.11",
43
+ "cross-env": "10.1.0",
44
+ "esbuild": "0.27.0",
44
45
  "eslint": "8.57.0",
45
46
  "eslint-cli": "1.1.1",
46
47
  "iframe-resizer": "4.3.6",
47
- "prettier": "^3.6.2",
48
- "typescript": "^5.9.3"
48
+ "prettier": "3.6.2",
49
+ "typescript": "5.9.3"
49
50
  }
50
51
  }
package/postal-mime.d.ts CHANGED
@@ -2,9 +2,17 @@ export type RawEmail = string | ArrayBuffer | Uint8Array | Blob | Buffer | Reada
2
2
 
3
3
  export type Header = {
4
4
  key: string;
5
+ originalKey: string;
5
6
  value: string;
6
7
  };
7
8
 
9
+ export type HeaderLine = {
10
+ /** Lowercase header name */
11
+ key: string;
12
+ /** Complete raw header line including key and value (with folded lines merged) */
13
+ line: string;
14
+ };
15
+
8
16
  export type Mailbox = {
9
17
  name: string;
10
18
  address: string;
@@ -27,12 +35,13 @@ export type Attachment = {
27
35
  description?: string;
28
36
  contentId?: string;
29
37
  method?: string;
30
- content: ArrayBuffer | string;
38
+ content: ArrayBuffer | Uint8Array | string;
31
39
  encoding?: "base64" | "utf8";
32
40
  };
33
41
 
34
42
  export type Email = {
35
43
  headers: Header[];
44
+ headerLines: HeaderLine[];
36
45
  from?: Address;
37
46
  sender?: Address;
38
47
  replyTo?: Address[];
@@ -4,9 +4,10 @@ import { decodeWords } from './decode-strings.js';
4
4
  * Converts tokens for a single address into an address object
5
5
  *
6
6
  * @param {Array} tokens Tokens object
7
+ * @param {Number} depth Current recursion depth for nested group protection
7
8
  * @return {Object} Address object
8
9
  */
9
- function _handleAddress(tokens) {
10
+ function _handleAddress(tokens, depth) {
10
11
  let isGroup = false;
11
12
  let state = 'text';
12
13
  let address;
@@ -87,7 +88,7 @@ function _handleAddress(tokens) {
87
88
  // Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting)
88
89
  let groupMembers = [];
89
90
  if (data.group.length) {
90
- let parsedGroup = addressParser(data.group.join(','));
91
+ let parsedGroup = addressParser(data.group.join(','), { _depth: depth + 1 });
91
92
  // Flatten: if any member is itself a group, extract its members into the sequence
92
93
  parsedGroup.forEach(member => {
93
94
  if (member.group) {
@@ -307,6 +308,13 @@ class Tokenizer {
307
308
  }
308
309
  }
309
310
 
311
+ /**
312
+ * Maximum recursion depth for parsing nested groups.
313
+ * RFC 5322 doesn't allow nested groups, so this is a safeguard against
314
+ * malicious input that could cause stack overflow.
315
+ */
316
+ const MAX_NESTED_GROUP_DEPTH = 50;
317
+
310
318
  /**
311
319
  * Parses structured e-mail addresses from an address field
312
320
  *
@@ -319,10 +327,18 @@ class Tokenizer {
319
327
  * [{name: 'Name', address: 'address@domain'}]
320
328
  *
321
329
  * @param {String} str Address field
330
+ * @param {Object} options Optional options object
331
+ * @param {Number} options._depth Internal recursion depth counter (do not set manually)
322
332
  * @return {Array} An array of address objects
323
333
  */
324
334
  function addressParser(str, options) {
325
335
  options = options || {};
336
+ let depth = options._depth || 0;
337
+
338
+ // Prevent stack overflow from deeply nested groups (DoS protection)
339
+ if (depth > MAX_NESTED_GROUP_DEPTH) {
340
+ return [];
341
+ }
326
342
 
327
343
  let tokenizer = new Tokenizer(str);
328
344
  let tokens = tokenizer.tokenize();
@@ -347,7 +363,7 @@ function addressParser(str, options) {
347
363
  }
348
364
 
349
365
  addresses.forEach(address => {
350
- address = _handleAddress(address);
366
+ address = _handleAddress(address, depth);
351
367
  if (address.length) {
352
368
  parsedAddresses = parsedAddresses.concat(address);
353
369
  }
package/src/mime-node.js CHANGED
@@ -208,38 +208,59 @@ export default class MimeNode {
208
208
  }
209
209
 
210
210
  processHeaders() {
211
+ // First pass: merge folded headers (backward iteration)
211
212
  for (let i = this.headerLines.length - 1; i >= 0; i--) {
212
213
  let line = this.headerLines[i];
213
214
  if (i && /^\s/.test(line)) {
214
215
  this.headerLines[i - 1] += '\n' + line;
215
216
  this.headerLines.splice(i, 1);
216
- } else {
217
- // remove folding and extra WS
218
- line = line.replace(/\s+/g, ' ');
219
- let sep = line.indexOf(':');
220
- let key = sep < 0 ? line.trim() : line.substr(0, sep).trim();
221
- let value = sep < 0 ? '' : line.substr(sep + 1).trim();
222
- this.headers.push({ key: key.toLowerCase(), originalKey: key, value });
223
-
224
- switch (key.toLowerCase()) {
225
- case 'content-type':
226
- if (this.contentType.default) {
227
- this.contentType = { value, parsed: {} };
228
- }
229
- break;
230
- case 'content-transfer-encoding':
231
- this.contentTransferEncoding = { value, parsed: {} };
232
- break;
233
- case 'content-disposition':
234
- this.contentDisposition = { value, parsed: {} };
235
- break;
236
- case 'content-id':
237
- this.contentId = value;
238
- break;
239
- case 'content-description':
240
- this.contentDescription = value;
241
- break;
242
- }
217
+ }
218
+ }
219
+
220
+ // Initialize rawHeaderLines to store unmodified lines
221
+ this.rawHeaderLines = [];
222
+
223
+ // Second pass: process headers (MUST be backward to maintain this.headers order)
224
+ // The existing code iterates backward and postal-mime.js calls .reverse()
225
+ // We must preserve this behavior to avoid breaking changes
226
+ for (let i = this.headerLines.length - 1; i >= 0; i--) {
227
+ let rawLine = this.headerLines[i];
228
+
229
+ // Extract key from raw line for rawHeaderLines
230
+ let sep = rawLine.indexOf(':');
231
+ let rawKey = sep < 0 ? rawLine.trim() : rawLine.substr(0, sep).trim();
232
+
233
+ // Store raw line with lowercase key
234
+ this.rawHeaderLines.push({
235
+ key: rawKey.toLowerCase(),
236
+ line: rawLine
237
+ });
238
+
239
+ // Normalize for this.headers (existing behavior - order preserved)
240
+ let normalizedLine = rawLine.replace(/\s+/g, ' ');
241
+ sep = normalizedLine.indexOf(':');
242
+ let key = sep < 0 ? normalizedLine.trim() : normalizedLine.substr(0, sep).trim();
243
+ let value = sep < 0 ? '' : normalizedLine.substr(sep + 1).trim();
244
+ this.headers.push({ key: key.toLowerCase(), originalKey: key, value });
245
+
246
+ switch (key.toLowerCase()) {
247
+ case 'content-type':
248
+ if (this.contentType.default) {
249
+ this.contentType = { value, parsed: {} };
250
+ }
251
+ break;
252
+ case 'content-transfer-encoding':
253
+ this.contentTransferEncoding = { value, parsed: {} };
254
+ break;
255
+ case 'content-disposition':
256
+ this.contentDisposition = { value, parsed: {} };
257
+ break;
258
+ case 'content-id':
259
+ this.contentId = value;
260
+ break;
261
+ case 'content-description':
262
+ this.contentDescription = value;
263
+ break;
243
264
  }
244
265
  }
245
266
 
@@ -517,6 +517,9 @@ export default class PostalMime {
517
517
 
518
518
  message.attachments = this.attachments;
519
519
 
520
+ // Expose raw header lines (reversed to match headers array order)
521
+ message.headerLines = (this.root.rawHeaderLines || []).slice().reverse();
522
+
520
523
  switch (this.attachmentEncoding) {
521
524
  case 'arraybuffer':
522
525
  break;