postal-mime 2.7.1 → 2.7.3

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.3](https://github.com/postalsys/postal-mime/compare/v2.7.2...v2.7.3) (2026-01-09)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * correct TypeScript type definitions to match implementation ([b225d7c](https://github.com/postalsys/postal-mime/commit/b225d7cca422cb9bc3ab5301e94c4c0bef9a69e2))
9
+
10
+ ## [2.7.2](https://github.com/postalsys/postal-mime/compare/v2.7.1...v2.7.2) (2026-01-08)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * add null checks for contentType.parsed access ([ad8f4c6](https://github.com/postalsys/postal-mime/commit/ad8f4c62e0972fd0244859ee5a5184b2cac26395))
16
+ * improve RFC compliance for MIME parsing ([e004c3a](https://github.com/postalsys/postal-mime/commit/e004c3acb29d72ed7eaf1b0b66351cf8b82b970d))
17
+
3
18
  ## [2.7.1](https://github.com/postalsys/postal-mime/compare/v2.7.0...v2.7.1) (2025-12-22)
4
19
 
5
20
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # postal-mime
2
2
 
3
- **postal-mime** is an email parsing library that runs in browser environments (including Web Workers) and serverless functions (like Cloudflare Email Workers). It takes in a raw email message (RFC822 format) and outputs a structured object containing headers, recipients, attachments, and more.
3
+ **postal-mime** is an email parsing library for Node.js, browsers (including Web Workers), and serverless environments (like Cloudflare Email Workers). It takes in a raw email message (RFC822 format) and outputs a structured object containing headers, recipients, attachments, and more.
4
4
 
5
5
  > [!TIP]
6
6
  > PostalMime is developed by the makers of [EmailEngine](https://emailengine.app/?utm_source=github&utm_campaign=imapflow&utm_medium=readme-link)—a self-hosted email gateway that provides a REST API for IMAP and SMTP servers and sends webhooks whenever something changes in registered accounts.
@@ -243,7 +243,7 @@ if (email.from && isMailbox(email.from)) {
243
243
  PostalMime.parse(email, options) -> Promise<Email>
244
244
  ```
245
245
 
246
- - **email**: An RFC822 formatted email. This can be a `string`, `ArrayBuffer/Uint8Array`, `Blob` (browser only), `Buffer` (Node.js), or a [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).
246
+ - **email**: An RFC822 formatted email. This can be a `string`, `ArrayBuffer/Uint8Array`, `Blob`, `Buffer` (Node.js), or a [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).
247
247
  - **options**: Optional configuration object:
248
248
  - **rfc822Attachments** (boolean, default: `false`): Treat `message/rfc822` attachments without a Content-Disposition as attachments.
249
249
  - **forceRfc822Attachments** (boolean, default: `false`): Treat _all_ `message/rfc822` parts as attachments.
@@ -419,6 +419,6 @@ console.log(decoded); // Hello, エポスカード
419
419
 
420
420
  ## License
421
421
 
422
- &copy; 2021–2025 Andris Reinman
422
+ &copy; 2021–2026 Andris Reinman
423
423
 
424
424
  `postal-mime` is licensed under the **MIT No Attribution license**.
@@ -54,8 +54,10 @@ class MimeNode {
54
54
  this.state = "header";
55
55
  this.headerLines = [];
56
56
  this.headerSize = 0;
57
+ const parentMultipartType = this.options.parentMultipartType || null;
58
+ const defaultContentType = parentMultipartType === "digest" ? "message/rfc822" : "text/plain";
57
59
  this.contentType = {
58
- value: "text/plain",
60
+ value: defaultContentType,
59
61
  default: true
60
62
  };
61
63
  this.contentTransferEncoding = {
@@ -100,7 +102,51 @@ class MimeNode {
100
102
  await childNode.finalize();
101
103
  }
102
104
  }
105
+ // Strip RFC 822 comments (parenthesized text) from structured header values
106
+ stripComments(str) {
107
+ let result = "";
108
+ let depth = 0;
109
+ let escaped = false;
110
+ let inQuote = false;
111
+ for (let i = 0; i < str.length; i++) {
112
+ const chr = str.charAt(i);
113
+ if (escaped) {
114
+ if (depth === 0) {
115
+ result += chr;
116
+ }
117
+ escaped = false;
118
+ continue;
119
+ }
120
+ if (chr === "\\") {
121
+ escaped = true;
122
+ if (depth === 0) {
123
+ result += chr;
124
+ }
125
+ continue;
126
+ }
127
+ if (chr === '"' && depth === 0) {
128
+ inQuote = !inQuote;
129
+ result += chr;
130
+ continue;
131
+ }
132
+ if (!inQuote) {
133
+ if (chr === "(") {
134
+ depth++;
135
+ continue;
136
+ }
137
+ if (chr === ")" && depth > 0) {
138
+ depth--;
139
+ continue;
140
+ }
141
+ }
142
+ if (depth === 0) {
143
+ result += chr;
144
+ }
145
+ }
146
+ return result;
147
+ }
103
148
  parseStructuredHeader(str) {
149
+ str = this.stripComments(str);
104
150
  let response = {
105
151
  value: false,
106
152
  params: {}
@@ -69,21 +69,33 @@ class PostalMime {
69
69
  if (boundaries.length && line.length > 2 && line[0] === 45 && line[1] === 45) {
70
70
  for (let i = boundaries.length - 1; i >= 0; i--) {
71
71
  let boundary = boundaries[i];
72
- if (line.length !== boundary.value.length + 2 && line.length !== boundary.value.length + 4) {
72
+ if (line.length < boundary.value.length + 2) {
73
73
  continue;
74
74
  }
75
- let isTerminator = line.length === boundary.value.length + 4;
76
- if (isTerminator && (line[line.length - 2] !== 45 || line[line.length - 1] !== 45)) {
75
+ let boundaryMatches = true;
76
+ for (let j = 0; j < boundary.value.length; j++) {
77
+ if (line[j + 2] !== boundary.value[j]) {
78
+ boundaryMatches = false;
79
+ break;
80
+ }
81
+ }
82
+ if (!boundaryMatches) {
77
83
  continue;
78
84
  }
79
- let boudaryMatches = true;
80
- for (let i2 = 0; i2 < boundary.value.length; i2++) {
81
- if (line[i2 + 2] !== boundary.value[i2]) {
82
- boudaryMatches = false;
85
+ let boundaryEnd = boundary.value.length + 2;
86
+ let isTerminator = false;
87
+ if (line.length >= boundary.value.length + 4 && line[boundary.value.length + 2] === 45 && line[boundary.value.length + 3] === 45) {
88
+ isTerminator = true;
89
+ boundaryEnd = boundary.value.length + 4;
90
+ }
91
+ let hasValidTrailing = true;
92
+ for (let j = boundaryEnd; j < line.length; j++) {
93
+ if (line[j] !== 32 && line[j] !== 9) {
94
+ hasValidTrailing = false;
83
95
  break;
84
96
  }
85
97
  }
86
- if (!boudaryMatches) {
98
+ if (!hasValidTrailing) {
87
99
  continue;
88
100
  }
89
101
  if (isTerminator) {
@@ -94,6 +106,7 @@ class PostalMime {
94
106
  this.currentNode = new import_mime_node.default({
95
107
  postalMime: this,
96
108
  parentNode: boundary.node,
109
+ parentMultipartType: boundary.node.contentType.multipart,
97
110
  ...this.mimeOptions
98
111
  });
99
112
  }
@@ -286,11 +299,11 @@ class PostalMime {
286
299
  this.textContent = textContent;
287
300
  }
288
301
  isInlineTextNode(node) {
289
- var _a, _b;
302
+ var _a, _b, _c;
290
303
  if (((_b = (_a = node.contentDisposition) == null ? void 0 : _a.parsed) == null ? void 0 : _b.value) === "attachment") {
291
304
  return false;
292
305
  }
293
- switch (node.contentType.parsed.value) {
306
+ switch ((_c = node.contentType.parsed) == null ? void 0 : _c.value) {
294
307
  case "text/html":
295
308
  case "text/plain":
296
309
  return true;
@@ -301,11 +314,11 @@ class PostalMime {
301
314
  }
302
315
  }
303
316
  isInlineMessageRfc822(node) {
304
- var _a, _b;
305
- if (node.contentType.parsed.value !== "message/rfc822") {
317
+ var _a, _b, _c;
318
+ if (((_a = node.contentType.parsed) == null ? void 0 : _a.value) !== "message/rfc822") {
306
319
  return false;
307
320
  }
308
- let disposition = ((_b = (_a = node.contentDisposition) == null ? void 0 : _a.parsed) == null ? void 0 : _b.value) || (this.options.rfc822Attachments ? "attachment" : "inline");
321
+ let disposition = ((_c = (_b = node.contentDisposition) == null ? void 0 : _b.parsed) == null ? void 0 : _c.value) || (this.options.rfc822Attachments ? "attachment" : "inline");
309
322
  return disposition === "inline";
310
323
  }
311
324
  // Check if this is a specially crafted report email where message/rfc822 content should not be inlined
@@ -316,7 +329,7 @@ class PostalMime {
316
329
  let forceRfc822Attachments = false;
317
330
  let walk = (node) => {
318
331
  if (!node.contentType.multipart) {
319
- if (["message/delivery-status", "message/feedback-report"].includes(node.contentType.parsed.value)) {
332
+ if (node.contentType.parsed && ["message/delivery-status", "message/feedback-report"].includes(node.contentType.parsed.value)) {
320
333
  forceRfc822Attachments = true;
321
334
  }
322
335
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "postal-mime",
3
- "version": "2.7.1",
4
- "description": "Email parser for browser environments",
3
+ "version": "2.7.3",
4
+ "description": "Email parser for Node.js and browser environments",
5
5
  "main": "./dist/postal-mime.cjs",
6
6
  "module": "./src/postal-mime.js",
7
7
  "exports": {
package/postal-mime.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export type RawEmail = string | ArrayBuffer | Uint8Array | Blob | Buffer | ReadableStream;
2
2
 
3
3
  export type Header = {
4
+ /** Lowercase header name */
4
5
  key: string;
5
- originalKey: string;
6
+ /** Header value */
6
7
  value: string;
7
8
  };
8
9
 
@@ -35,7 +36,7 @@ export type Attachment = {
35
36
  description?: string;
36
37
  contentId?: string;
37
38
  method?: string;
38
- content: ArrayBuffer | Uint8Array | string;
39
+ content: ArrayBuffer | string;
39
40
  encoding?: "base64" | "utf8";
40
41
  };
41
42
 
package/src/mime-node.js CHANGED
@@ -30,8 +30,12 @@ export default class MimeNode {
30
30
  this.headerLines = [];
31
31
  this.headerSize = 0;
32
32
 
33
+ // RFC 2046 Section 5.1.5: multipart/digest defaults to message/rfc822
34
+ const parentMultipartType = this.options.parentMultipartType || null;
35
+ const defaultContentType = parentMultipartType === 'digest' ? 'message/rfc822' : 'text/plain';
36
+
33
37
  this.contentType = {
34
- value: 'text/plain',
38
+ value: defaultContentType,
35
39
  default: true
36
40
  };
37
41
 
@@ -90,7 +94,61 @@ export default class MimeNode {
90
94
  }
91
95
  }
92
96
 
97
+ // Strip RFC 822 comments (parenthesized text) from structured header values
98
+ stripComments(str) {
99
+ let result = '';
100
+ let depth = 0;
101
+ let escaped = false;
102
+ let inQuote = false;
103
+
104
+ for (let i = 0; i < str.length; i++) {
105
+ const chr = str.charAt(i);
106
+
107
+ if (escaped) {
108
+ if (depth === 0) {
109
+ result += chr;
110
+ }
111
+ escaped = false;
112
+ continue;
113
+ }
114
+
115
+ if (chr === '\\') {
116
+ escaped = true;
117
+ if (depth === 0) {
118
+ result += chr;
119
+ }
120
+ continue;
121
+ }
122
+
123
+ if (chr === '"' && depth === 0) {
124
+ inQuote = !inQuote;
125
+ result += chr;
126
+ continue;
127
+ }
128
+
129
+ if (!inQuote) {
130
+ if (chr === '(') {
131
+ depth++;
132
+ continue;
133
+ }
134
+ if (chr === ')' && depth > 0) {
135
+ depth--;
136
+ continue;
137
+ }
138
+ }
139
+
140
+ if (depth === 0) {
141
+ result += chr;
142
+ }
143
+ }
144
+
145
+ return result;
146
+ }
147
+
93
148
  parseStructuredHeader(str) {
149
+ // Strip RFC 822 comments before parsing
150
+ str = this.stripComments(str);
151
+
94
152
  let response = {
95
153
  value: false,
96
154
  params: {}
@@ -55,24 +55,43 @@ export default class PostalMime {
55
55
  for (let i = boundaries.length - 1; i >= 0; i--) {
56
56
  let boundary = boundaries[i];
57
57
 
58
- if (line.length !== boundary.value.length + 2 && line.length !== boundary.value.length + 4) {
58
+ // Line must be at least long enough for "--" + boundary
59
+ if (line.length < boundary.value.length + 2) {
59
60
  continue;
60
61
  }
61
62
 
62
- let isTerminator = line.length === boundary.value.length + 4;
63
-
64
- if (isTerminator && (line[line.length - 2] !== 0x2d || line[line.length - 1] !== 0x2d)) {
63
+ // Check if boundary value matches
64
+ let boundaryMatches = true;
65
+ for (let j = 0; j < boundary.value.length; j++) {
66
+ if (line[j + 2] !== boundary.value[j]) {
67
+ boundaryMatches = false;
68
+ break;
69
+ }
70
+ }
71
+ if (!boundaryMatches) {
65
72
  continue;
66
73
  }
67
74
 
68
- let boudaryMatches = true;
69
- for (let i = 0; i < boundary.value.length; i++) {
70
- if (line[i + 2] !== boundary.value[i]) {
71
- boudaryMatches = false;
75
+ // Check for terminator (-- after boundary) and determine where boundary ends
76
+ let boundaryEnd = boundary.value.length + 2;
77
+ let isTerminator = false;
78
+
79
+ if (line.length >= boundary.value.length + 4 &&
80
+ line[boundary.value.length + 2] === 0x2d &&
81
+ line[boundary.value.length + 3] === 0x2d) {
82
+ isTerminator = true;
83
+ boundaryEnd = boundary.value.length + 4;
84
+ }
85
+
86
+ // RFC 2046: boundary line may have trailing whitespace (space/tab) before CRLF
87
+ let hasValidTrailing = true;
88
+ for (let j = boundaryEnd; j < line.length; j++) {
89
+ if (line[j] !== 0x20 && line[j] !== 0x09) {
90
+ hasValidTrailing = false;
72
91
  break;
73
92
  }
74
93
  }
75
- if (!boudaryMatches) {
94
+ if (!hasValidTrailing) {
76
95
  continue;
77
96
  }
78
97
 
@@ -87,6 +106,7 @@ export default class PostalMime {
87
106
  this.currentNode = new MimeNode({
88
107
  postalMime: this,
89
108
  parentNode: boundary.node,
109
+ parentMultipartType: boundary.node.contentType.multipart,
90
110
  ...this.mimeOptions
91
111
  });
92
112
  }
@@ -338,7 +358,7 @@ export default class PostalMime {
338
358
  return false;
339
359
  }
340
360
 
341
- switch (node.contentType.parsed.value) {
361
+ switch (node.contentType.parsed?.value) {
342
362
  case 'text/html':
343
363
  case 'text/plain':
344
364
  return true;
@@ -351,7 +371,7 @@ export default class PostalMime {
351
371
  }
352
372
 
353
373
  isInlineMessageRfc822(node) {
354
- if (node.contentType.parsed.value !== 'message/rfc822') {
374
+ if (node.contentType.parsed?.value !== 'message/rfc822') {
355
375
  return false;
356
376
  }
357
377
  let disposition =
@@ -368,7 +388,10 @@ export default class PostalMime {
368
388
  let forceRfc822Attachments = false;
369
389
  let walk = node => {
370
390
  if (!node.contentType.multipart) {
371
- if (['message/delivery-status', 'message/feedback-report'].includes(node.contentType.parsed.value)) {
391
+ if (
392
+ node.contentType.parsed &&
393
+ ['message/delivery-status', 'message/feedback-report'].includes(node.contentType.parsed.value)
394
+ ) {
372
395
  forceRfc822Attachments = true;
373
396
  }
374
397
  }