postal-mime 2.4.4 → 2.4.6

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.
@@ -0,0 +1,9 @@
1
+ node_modules
2
+ dist
3
+ .git
4
+ *.min.js
5
+ coverage
6
+ .DS_Store
7
+ yarn.lock
8
+ package-lock.json
9
+ CLAUDE.md
@@ -0,0 +1,10 @@
1
+ {
2
+ "semi": true,
3
+ "trailingComma": "none",
4
+ "singleQuote": true,
5
+ "printWidth": 120,
6
+ "tabWidth": 4,
7
+ "useTabs": false,
8
+ "bracketSpacing": true,
9
+ "arrowParens": "avoid"
10
+ }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.6](https://github.com/postalsys/postal-mime/compare/v2.4.5...v2.4.6) (2025-10-01)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * add security limits for MIME parsing ([defbf11](https://github.com/postalsys/postal-mime/commit/defbf11e85c8233e6ff01a3d6fc10534b784c499))
9
+
10
+ ## [2.4.5](https://github.com/postalsys/postal-mime/compare/v2.4.4...v2.4.5) (2025-09-29)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * handle broken quoted-printable sequences and add prettier formatter ([ec3bc64](https://github.com/postalsys/postal-mime/commit/ec3bc647f6e262a7ac3a51e30bbe3b07b6e7db7a))
16
+
3
17
  ## [2.4.4](https://github.com/postalsys/postal-mime/compare/v2.4.3...v2.4.4) (2025-06-26)
4
18
 
5
19
 
package/README.md CHANGED
@@ -111,6 +111,8 @@ PostalMime.parse(email, options) -> Promise<ParsedEmail>
111
111
  - `"base64"`
112
112
  - `"utf8"`
113
113
  - `"arraybuffer"` (no decoding, returns `ArrayBuffer`)
114
+ - **maxNestingDepth** (number, default: `256`): Maximum allowed MIME part nesting depth. Throws an error if exceeded.
115
+ - **maxHeadersSize** (number, default: `2097152`): Maximum allowed total header size in bytes (default 2MB). Throws an error if exceeded.
114
116
 
115
117
  **Returns**: A Promise that resolves to a structured object with the following properties:
116
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postal-mime",
3
- "version": "2.4.4",
3
+ "version": "2.4.6",
4
4
  "description": "Email parser for browser environments",
5
5
  "main": "./src/postal-mime.js",
6
6
  "exports": {
@@ -12,7 +12,9 @@
12
12
  "types": "postal-mime.d.ts",
13
13
  "scripts": {
14
14
  "test": "eslint && node --test",
15
- "update": "rm -rf node_modules package-lock.json && ncu -u && npm install"
15
+ "update": "rm -rf node_modules package-lock.json && ncu -u && npm install",
16
+ "format": "prettier --write \"**/*.{js,mjs,json}\" --ignore-path .prettierignore",
17
+ "format:check": "prettier --check \"**/*.{js,mjs,json}\" --ignore-path .prettierignore"
16
18
  },
17
19
  "keywords": [
18
20
  "mime",
@@ -33,6 +35,7 @@
33
35
  "cross-env": "7.0.3",
34
36
  "eslint": "8.57.0",
35
37
  "eslint-cli": "1.1.1",
36
- "iframe-resizer": "4.3.6"
38
+ "iframe-resizer": "4.3.6",
39
+ "prettier": "^3.6.2"
37
40
  }
38
41
  }
package/postal-mime.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export type RawEmail = string | ArrayBuffer | Uint8Array | Blob | Buffer | ReadableStream;
2
2
 
3
- export type Header = Record<string, string>;
3
+ export type Header = {
4
+ key: string;
5
+ value: string;
6
+ };
4
7
 
5
8
  export type Address = {
6
9
  name: string;
@@ -22,7 +25,7 @@ export type Attachment = {
22
25
 
23
26
  export type Email = {
24
27
  headers: Header[];
25
- from: Address;
28
+ from?: Address;
26
29
  sender?: Address;
27
30
  replyTo?: Address[];
28
31
  deliveredTo?: string;
@@ -31,7 +34,7 @@ export type Email = {
31
34
  cc?: Address[];
32
35
  bcc?: Address[];
33
36
  subject?: string;
34
- messageId: string;
37
+ messageId?: string;
35
38
  inReplyTo?: string;
36
39
  references?: string;
37
40
  date?: string;
@@ -56,7 +59,9 @@ declare function decodeWords (
56
59
  declare type PostalMimeOptions = {
57
60
  rfc822Attachments?: boolean,
58
61
  forceRfc822Attachments?: boolean,
59
- attachmentEncoding?: "base64" | "utf8" | "arraybuffer"
62
+ attachmentEncoding?: "base64" | "utf8" | "arraybuffer",
63
+ maxNestingDepth?: number,
64
+ maxHeadersSize?: number
60
65
  }
61
66
 
62
67
  declare class PostalMime {
@@ -81,7 +81,11 @@ export async function blobToArrayBuffer(blob) {
81
81
  }
82
82
 
83
83
  export function getHex(c) {
84
- if ((c >= 0x30 /* 0 */ && c <= 0x39) /* 9 */ || (c >= 0x61 /* a */ && c <= 0x66) /* f */ || (c >= 0x41 /* A */ && c <= 0x46) /* F */) {
84
+ if (
85
+ (c >= 0x30 /* 0 */ && c <= 0x39) /* 9 */ ||
86
+ (c >= 0x61 /* a */ && c <= 0x66) /* f */ ||
87
+ (c >= 0x41 /* A */ && c <= 0x46) /* F */
88
+ ) {
85
89
  return String.fromCharCode(c);
86
90
  }
87
91
  return false;
@@ -152,36 +156,44 @@ export function decodeWords(str) {
152
156
  let result = (str || '')
153
157
  .toString()
154
158
  // find base64 words that can be joined
155
- .replace(/(=\?([^?]+)\?[Bb]\?([^?]*)\?=)\s*(?==\?([^?]+)\?[Bb]\?[^?]*\?=)/g, (match, left, chLeft, encodedLeftStr, chRight) => {
156
- if (!joinString) {
159
+ .replace(
160
+ /(=\?([^?]+)\?[Bb]\?([^?]*)\?=)\s*(?==\?([^?]+)\?[Bb]\?[^?]*\?=)/g,
161
+ (match, left, chLeft, encodedLeftStr, chRight) => {
162
+ if (!joinString) {
163
+ return match;
164
+ }
165
+ // only mark b64 chunks to be joined if charsets match and left side does not end with =
166
+ if (chLeft === chRight && encodedLeftStr.length % 4 === 0 && !/=$/.test(encodedLeftStr)) {
167
+ // set a joiner marker
168
+ return left + '__\x00JOIN\x00__';
169
+ }
170
+
157
171
  return match;
158
172
  }
159
- // only mark b64 chunks to be joined if charsets match and left side does not end with =
160
- if (chLeft === chRight && encodedLeftStr.length % 4 === 0 && !/=$/.test(encodedLeftStr)) {
161
- // set a joiner marker
162
- return left + '__\x00JOIN\x00__';
163
- }
164
-
165
- return match;
166
- })
173
+ )
167
174
  // find QP words that can be joined
168
- .replace(/(=\?([^?]+)\?[Qq]\?[^?]*\?=)\s*(?==\?([^?]+)\?[Qq]\?[^?]*\?=)/g, (match, left, chLeft, chRight) => {
169
- if (!joinString) {
175
+ .replace(
176
+ /(=\?([^?]+)\?[Qq]\?[^?]*\?=)\s*(?==\?([^?]+)\?[Qq]\?[^?]*\?=)/g,
177
+ (match, left, chLeft, chRight) => {
178
+ if (!joinString) {
179
+ return match;
180
+ }
181
+ // only mark QP chunks to be joined if charsets match
182
+ if (chLeft === chRight) {
183
+ // set a joiner marker
184
+ return left + '__\x00JOIN\x00__';
185
+ }
170
186
  return match;
171
187
  }
172
- // only mark QP chunks to be joined if charsets match
173
- if (chLeft === chRight) {
174
- // set a joiner marker
175
- return left + '__\x00JOIN\x00__';
176
- }
177
- return match;
178
- })
188
+ )
179
189
  // join base64 encoded words
180
190
  .replace(/(\?=)?__\x00JOIN\x00__(=\?([^?]+)\?[QqBb]\?)?/g, '')
181
191
  // remove spaces between mime encoded words
182
192
  .replace(/(=\?[^?]+\?[QqBb]\?[^?]*\?=)\s+(?==\?[^?]+\?[QqBb]\?[^?]*\?=)/g, '$1')
183
193
  // decode words
184
- .replace(/=\?([\w_\-*]+)\?([QqBb])\?([^?]*)\?=/g, (m, charset, encoding, text) => decodeWord(charset, encoding, text));
194
+ .replace(/=\?([\w_\-*]+)\?([QqBb])\?([^?]*)\?=/g, (m, charset, encoding, text) =>
195
+ decodeWord(charset, encoding, text)
196
+ );
185
197
 
186
198
  if (joinString && result.indexOf('\ufffd') >= 0) {
187
199
  // text contains \ufffd (EF BF BD), so unicode conversion failed, retry without joining strings
package/src/mime-node.js CHANGED
@@ -4,20 +4,31 @@ import Base64Decoder from './base64-decoder.js';
4
4
  import QPDecoder from './qp-decoder.js';
5
5
 
6
6
  export default class MimeNode {
7
- constructor(opts) {
8
- opts = opts || {};
7
+ constructor(options) {
8
+ this.options = options || {};
9
9
 
10
- this.postalMime = opts.postalMime;
10
+ this.postalMime = this.options.postalMime;
11
11
 
12
- this.root = !!opts.parentNode;
12
+ this.root = !!this.options.parentNode;
13
13
  this.childNodes = [];
14
- if (opts.parentNode) {
15
- opts.parentNode.childNodes.push(this);
14
+
15
+ if (this.options.parentNode) {
16
+ this.parentNode = this.options.parentNode;
17
+
18
+ this.depth = this.parentNode.depth + 1;
19
+ if (this.depth > this.options.maxNestingDepth) {
20
+ throw new Error(`Maximum MIME nesting depth of ${this.options.maxNestingDepth} levels exceeded`);
21
+ }
22
+
23
+ this.options.parentNode.childNodes.push(this);
24
+ } else {
25
+ this.depth = 0;
16
26
  }
17
27
 
18
28
  this.state = 'header';
19
29
 
20
30
  this.headerLines = [];
31
+ this.headerSize = 0;
21
32
 
22
33
  this.contentType = {
23
34
  value: 'text/plain',
@@ -262,6 +273,14 @@ export default class MimeNode {
262
273
  this.state = 'body';
263
274
  return this.processHeaders();
264
275
  }
276
+
277
+ this.headerSize += line.length;
278
+
279
+ if (this.headerSize > this.options.maxHeadersSize) {
280
+ let error = new Error(`Maximum header size of ${this.options.maxHeadersSize} bytes exceeded`);
281
+ throw error;
282
+ }
283
+
265
284
  this.headerLines.push(getDecoder().decode(line));
266
285
  break;
267
286
  case 'body': {
@@ -6,6 +6,9 @@ import { base64ArrayBuffer } from './base64-encoder.js';
6
6
 
7
7
  export { addressParser, decodeWords };
8
8
 
9
+ const MAX_NESTING_DEPTH = 256;
10
+ const MAX_HEADERS_SIZE = 2 * 1024 * 1024;
11
+
9
12
  export default class PostalMime {
10
13
  static parse(buf, options) {
11
14
  const parser = new PostalMime(options);
@@ -14,9 +17,14 @@ export default class PostalMime {
14
17
 
15
18
  constructor(options) {
16
19
  this.options = options || {};
20
+ this.mimeOptions = {
21
+ maxNestingDepth: this.options.maxNestingDepth || MAX_NESTING_DEPTH,
22
+ maxHeadersSize: this.options.maxHeadersSize || MAX_HEADERS_SIZE
23
+ };
17
24
 
18
25
  this.root = this.currentNode = new MimeNode({
19
- postalMime: this
26
+ postalMime: this,
27
+ ...this.mimeOptions
20
28
  });
21
29
  this.boundaries = [];
22
30
 
@@ -78,7 +86,8 @@ export default class PostalMime {
78
86
 
79
87
  this.currentNode = new MimeNode({
80
88
  postalMime: this,
81
- parentNode: boundary.node
89
+ parentNode: boundary.node,
90
+ ...this.mimeOptions
82
91
  });
83
92
  }
84
93
 
@@ -190,7 +199,8 @@ export default class PostalMime {
190
199
 
191
200
  // is it an attachment
192
201
  else if (node.content) {
193
- const filename = node.contentDisposition.parsed.params.filename || node.contentType.parsed.params.name || null;
202
+ const filename =
203
+ node.contentDisposition.parsed.params.filename || node.contentType.parsed.params.name || null;
194
204
  const attachment = {
195
205
  filename: filename ? decodeWords(filename) : null,
196
206
  mimeType: node.contentType.parsed.value,
@@ -214,7 +224,10 @@ export default class PostalMime {
214
224
  case 'text/calendar':
215
225
  case 'application/ics': {
216
226
  if (node.contentType.parsed.params.method) {
217
- attachment.method = node.contentType.parsed.params.method.toString().toUpperCase().trim();
227
+ attachment.method = node.contentType.parsed.params.method
228
+ .toString()
229
+ .toUpperCase()
230
+ .trim();
218
231
  }
219
232
 
220
233
  // Enforce into unicode
@@ -341,7 +354,8 @@ export default class PostalMime {
341
354
  if (node.contentType.parsed.value !== 'message/rfc822') {
342
355
  return false;
343
356
  }
344
- let disposition = node.contentDisposition.parsed.value || (this.options.rfc822Attachments ? 'attachment' : 'inline');
357
+ let disposition =
358
+ node.contentDisposition.parsed.value || (this.options.rfc822Attachments ? 'attachment' : 'inline');
345
359
  return disposition === 'inline';
346
360
  }
347
361
 
package/src/qp-decoder.js CHANGED
@@ -1,5 +1,11 @@
1
1
  import { blobToArrayBuffer } from './decode-strings.js';
2
2
 
3
+ // Regex patterns compiled once for performance
4
+ const VALID_QP_REGEX = /^=[a-f0-9]{2}$/i;
5
+ const QP_SPLIT_REGEX = /(?==[a-f0-9]{2})/i;
6
+ const SOFT_LINE_BREAK_REGEX = /=\r?\n/g;
7
+ const PARTIAL_QP_ENDING_REGEX = /=[a-fA-F0-9]?$/;
8
+
3
9
  export default class QPDecoder {
4
10
  constructor(opts) {
5
11
  opts = opts || {};
@@ -24,9 +30,9 @@ export default class QPDecoder {
24
30
 
25
31
  decodeChunks(str) {
26
32
  // unwrap newlines
27
- str = str.replace(/=\r?\n/g, '');
33
+ str = str.replace(SOFT_LINE_BREAK_REGEX, '');
28
34
 
29
- let list = str.split(/(?==)/);
35
+ let list = str.split(QP_SPLIT_REGEX);
30
36
  let encodedBytes = [];
31
37
  for (let part of list) {
32
38
  if (part.charAt(0) !== '=') {
@@ -39,17 +45,38 @@ export default class QPDecoder {
39
45
  }
40
46
 
41
47
  if (part.length === 3) {
42
- encodedBytes.push(part.substr(1));
48
+ // Validate that this is actually a valid QP sequence
49
+ if (VALID_QP_REGEX.test(part)) {
50
+ encodedBytes.push(part.substr(1));
51
+ } else {
52
+ // Not a valid QP sequence, treat as literal text
53
+ if (encodedBytes.length) {
54
+ this.chunks.push(this.decodeQPBytes(encodedBytes));
55
+ encodedBytes = [];
56
+ }
57
+ this.chunks.push(part);
58
+ }
43
59
  continue;
44
60
  }
45
61
 
46
62
  if (part.length > 3) {
47
- encodedBytes.push(part.substr(1, 2));
48
- this.chunks.push(this.decodeQPBytes(encodedBytes));
49
- encodedBytes = [];
63
+ // First 3 chars should be a valid QP sequence
64
+ const firstThree = part.substr(0, 3);
65
+ if (VALID_QP_REGEX.test(firstThree)) {
66
+ encodedBytes.push(part.substr(1, 2));
67
+ this.chunks.push(this.decodeQPBytes(encodedBytes));
68
+ encodedBytes = [];
50
69
 
51
- part = part.substr(3);
52
- this.chunks.push(part);
70
+ part = part.substr(3);
71
+ this.chunks.push(part);
72
+ } else {
73
+ // Not a valid QP sequence, treat entire part as literal
74
+ if (encodedBytes.length) {
75
+ this.chunks.push(this.decodeQPBytes(encodedBytes));
76
+ encodedBytes = [];
77
+ }
78
+ this.chunks.push(part);
79
+ }
53
80
  }
54
81
  }
55
82
  if (encodedBytes.length) {
@@ -71,7 +98,7 @@ export default class QPDecoder {
71
98
 
72
99
  this.remainder = '';
73
100
 
74
- let partialEnding = str.match(/=[a-fA-F0-9]?$/);
101
+ let partialEnding = str.match(PARTIAL_QP_ENDING_REGEX);
75
102
  if (partialEnding) {
76
103
  if (partialEnding.index === 0) {
77
104
  this.remainder = str;
@@ -177,7 +177,10 @@ function foldLines(str, lineLength, afterSpace) {
177
177
  result += line;
178
178
  pos += line.length;
179
179
  continue;
180
- } else if ((match = line.match(/(\s+)[^\s]*$/)) && match[0].length - (afterSpace ? (match[1] || '').length : 0) < line.length) {
180
+ } else if (
181
+ (match = line.match(/(\s+)[^\s]*$/)) &&
182
+ match[0].length - (afterSpace ? (match[1] || '').length : 0) < line.length
183
+ ) {
181
184
  line = line.substr(0, line.length - (match[0].length - (afterSpace ? (match[1] || '').length : 0)));
182
185
  } else if ((match = str.substr(pos + line.length).match(/^[^\s]+(\s*)/))) {
183
186
  line = line + match[0].substr(0, match[0].length - (!afterSpace ? (match[1] || '').length : 0));
@@ -215,7 +218,10 @@ export function formatTextHeader(message) {
215
218
  hour12: false
216
219
  };
217
220
 
218
- let dateStr = typeof Intl === 'undefined' ? message.date : new Intl.DateTimeFormat('default', dateOptions).format(new Date(message.date));
221
+ let dateStr =
222
+ typeof Intl === 'undefined'
223
+ ? message.date
224
+ : new Intl.DateTimeFormat('default', dateOptions).format(new Date(message.date));
219
225
 
220
226
  rows.push({ key: 'Date', val: dateStr });
221
227
  }
@@ -283,7 +289,9 @@ export function formatHtmlHeader(message) {
283
289
  let rows = [];
284
290
 
285
291
  if (message.from) {
286
- rows.push(`<div class="postal-email-header-key">From</div><div class="postal-email-header-value">${formatHtmlAddress(message.from)}</div>`);
292
+ rows.push(
293
+ `<div class="postal-email-header-key">From</div><div class="postal-email-header-value">${formatHtmlAddress(message.from)}</div>`
294
+ );
287
295
  }
288
296
 
289
297
  if (message.subject) {
@@ -305,7 +313,10 @@ export function formatHtmlHeader(message) {
305
313
  hour12: false
306
314
  };
307
315
 
308
- let dateStr = typeof Intl === 'undefined' ? message.date : new Intl.DateTimeFormat('default', dateOptions).format(new Date(message.date));
316
+ let dateStr =
317
+ typeof Intl === 'undefined'
318
+ ? message.date
319
+ : new Intl.DateTimeFormat('default', dateOptions).format(new Date(message.date));
309
320
 
310
321
  rows.push(
311
322
  `<div class="postal-email-header-key">Date</div><div class="postal-email-header-value postal-email-header-date" data-date="${escapeHtml(
@@ -315,15 +326,21 @@ export function formatHtmlHeader(message) {
315
326
  }
316
327
 
317
328
  if (message.to && message.to.length) {
318
- rows.push(`<div class="postal-email-header-key">To</div><div class="postal-email-header-value">${formatHtmlAddresses(message.to)}</div>`);
329
+ rows.push(
330
+ `<div class="postal-email-header-key">To</div><div class="postal-email-header-value">${formatHtmlAddresses(message.to)}</div>`
331
+ );
319
332
  }
320
333
 
321
334
  if (message.cc && message.cc.length) {
322
- rows.push(`<div class="postal-email-header-key">Cc</div><div class="postal-email-header-value">${formatHtmlAddresses(message.cc)}</div>`);
335
+ rows.push(
336
+ `<div class="postal-email-header-key">Cc</div><div class="postal-email-header-value">${formatHtmlAddresses(message.cc)}</div>`
337
+ );
323
338
  }
324
339
 
325
340
  if (message.bcc && message.bcc.length) {
326
- rows.push(`<div class="postal-email-header-key">Bcc</div><div class="postal-email-header-value">${formatHtmlAddresses(message.bcc)}</div>`);
341
+ rows.push(
342
+ `<div class="postal-email-header-key">Bcc</div><div class="postal-email-header-value">${formatHtmlAddresses(message.bcc)}</div>`
343
+ );
327
344
  }
328
345
 
329
346
  let template = `<div class="postal-email-header">${rows.length ? '<div class="postal-email-header-row">' : ''}${rows.join(