postal-mime 2.7.0 → 2.7.2

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.2](https://github.com/postalsys/postal-mime/compare/v2.7.1...v2.7.2) (2026-01-08)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * add null checks for contentType.parsed access ([ad8f4c6](https://github.com/postalsys/postal-mime/commit/ad8f4c62e0972fd0244859ee5a5184b2cac26395))
9
+ * improve RFC compliance for MIME parsing ([e004c3a](https://github.com/postalsys/postal-mime/commit/e004c3acb29d72ed7eaf1b0b66351cf8b82b970d))
10
+
11
+ ## [2.7.1](https://github.com/postalsys/postal-mime/compare/v2.7.0...v2.7.1) (2025-12-22)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * Add null checks for contentDisposition.parsed access ([fd54c37](https://github.com/postalsys/postal-mime/commit/fd54c37093cc64737c6bb17986bc9d052d2d5add))
17
+
3
18
  ## [2.7.0](https://github.com/postalsys/postal-mime/compare/v2.6.1...v2.7.0) (2025-12-22)
4
19
 
5
20
 
@@ -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
  }
@@ -134,6 +147,7 @@ class PostalMime {
134
147
  let textMap = this.textMap = /* @__PURE__ */ new Map();
135
148
  let forceRfc822Attachments = this.forceRfc822Attachments();
136
149
  let walk = async (node, alternative, related) => {
150
+ var _a, _b, _c, _d, _e;
137
151
  alternative = alternative || false;
138
152
  related = related || false;
139
153
  if (!node.contentType.multipart) {
@@ -173,11 +187,11 @@ class PostalMime {
173
187
  textEntry[textType].push({ type: "text", value: node.getTextContent() });
174
188
  textTypes.add(textType);
175
189
  } else if (node.content) {
176
- const filename = node.contentDisposition.parsed.params.filename || node.contentType.parsed.params.name || null;
190
+ const filename = ((_c = (_b = (_a = node.contentDisposition) == null ? void 0 : _a.parsed) == null ? void 0 : _b.params) == null ? void 0 : _c.filename) || node.contentType.parsed.params.name || null;
177
191
  const attachment = {
178
192
  filename: filename ? (0, import_decode_strings.decodeWords)(filename) : null,
179
193
  mimeType: node.contentType.parsed.value,
180
- disposition: node.contentDisposition.parsed.value || null
194
+ disposition: ((_e = (_d = node.contentDisposition) == null ? void 0 : _d.parsed) == null ? void 0 : _e.value) || null
181
195
  };
182
196
  if (related && node.contentId) {
183
197
  attachment.related = true;
@@ -285,10 +299,11 @@ class PostalMime {
285
299
  this.textContent = textContent;
286
300
  }
287
301
  isInlineTextNode(node) {
288
- if (node.contentDisposition.parsed.value === "attachment") {
302
+ var _a, _b, _c;
303
+ if (((_b = (_a = node.contentDisposition) == null ? void 0 : _a.parsed) == null ? void 0 : _b.value) === "attachment") {
289
304
  return false;
290
305
  }
291
- switch (node.contentType.parsed.value) {
306
+ switch ((_c = node.contentType.parsed) == null ? void 0 : _c.value) {
292
307
  case "text/html":
293
308
  case "text/plain":
294
309
  return true;
@@ -299,10 +314,11 @@ class PostalMime {
299
314
  }
300
315
  }
301
316
  isInlineMessageRfc822(node) {
302
- 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") {
303
319
  return false;
304
320
  }
305
- let disposition = node.contentDisposition.parsed.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");
306
322
  return disposition === "inline";
307
323
  }
308
324
  // Check if this is a specially crafted report email where message/rfc822 content should not be inlined
@@ -313,7 +329,7 @@ class PostalMime {
313
329
  let forceRfc822Attachments = false;
314
330
  let walk = (node) => {
315
331
  if (!node.contentType.multipart) {
316
- 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)) {
317
333
  forceRfc822Attachments = true;
318
334
  }
319
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postal-mime",
3
- "version": "2.7.0",
3
+ "version": "2.7.2",
4
4
  "description": "Email parser for browser environments",
5
5
  "main": "./dist/postal-mime.cjs",
6
6
  "module": "./src/postal-mime.js",
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
  }
@@ -200,11 +220,11 @@ export default class PostalMime {
200
220
  // is it an attachment
201
221
  else if (node.content) {
202
222
  const filename =
203
- node.contentDisposition.parsed.params.filename || node.contentType.parsed.params.name || null;
223
+ node.contentDisposition?.parsed?.params?.filename || node.contentType.parsed.params.name || null;
204
224
  const attachment = {
205
225
  filename: filename ? decodeWords(filename) : null,
206
226
  mimeType: node.contentType.parsed.value,
207
- disposition: node.contentDisposition.parsed.value || null
227
+ disposition: node.contentDisposition?.parsed?.value || null
208
228
  };
209
229
 
210
230
  if (related && node.contentId) {
@@ -333,12 +353,12 @@ export default class PostalMime {
333
353
  }
334
354
 
335
355
  isInlineTextNode(node) {
336
- if (node.contentDisposition.parsed.value === 'attachment') {
356
+ if (node.contentDisposition?.parsed?.value === 'attachment') {
337
357
  // no matter the type, this is an attachment
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,11 +371,11 @@ 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 =
358
- node.contentDisposition.parsed.value || (this.options.rfc822Attachments ? 'attachment' : 'inline');
378
+ node.contentDisposition?.parsed?.value || (this.options.rfc822Attachments ? 'attachment' : 'inline');
359
379
  return disposition === 'inline';
360
380
  }
361
381
 
@@ -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
  }