postal-mime 2.4.4 → 2.4.5

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.5](https://github.com/postalsys/postal-mime/compare/v2.4.4...v2.4.5) (2025-09-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * handle broken quoted-printable sequences and add prettier formatter ([ec3bc64](https://github.com/postalsys/postal-mime/commit/ec3bc647f6e262a7ac3a51e30bbe3b07b6e7db7a))
9
+
3
10
  ## [2.4.4](https://github.com/postalsys/postal-mime/compare/v2.4.3...v2.4.4) (2025-06-26)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postal-mime",
3
- "version": "2.4.4",
3
+ "version": "2.4.5",
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
  }
@@ -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
@@ -190,7 +190,8 @@ export default class PostalMime {
190
190
 
191
191
  // is it an attachment
192
192
  else if (node.content) {
193
- const filename = node.contentDisposition.parsed.params.filename || node.contentType.parsed.params.name || null;
193
+ const filename =
194
+ node.contentDisposition.parsed.params.filename || node.contentType.parsed.params.name || null;
194
195
  const attachment = {
195
196
  filename: filename ? decodeWords(filename) : null,
196
197
  mimeType: node.contentType.parsed.value,
@@ -214,7 +215,10 @@ export default class PostalMime {
214
215
  case 'text/calendar':
215
216
  case 'application/ics': {
216
217
  if (node.contentType.parsed.params.method) {
217
- attachment.method = node.contentType.parsed.params.method.toString().toUpperCase().trim();
218
+ attachment.method = node.contentType.parsed.params.method
219
+ .toString()
220
+ .toUpperCase()
221
+ .trim();
218
222
  }
219
223
 
220
224
  // Enforce into unicode
@@ -341,7 +345,8 @@ export default class PostalMime {
341
345
  if (node.contentType.parsed.value !== 'message/rfc822') {
342
346
  return false;
343
347
  }
344
- let disposition = node.contentDisposition.parsed.value || (this.options.rfc822Attachments ? 'attachment' : 'inline');
348
+ let disposition =
349
+ node.contentDisposition.parsed.value || (this.options.rfc822Attachments ? 'attachment' : 'inline');
345
350
  return disposition === 'inline';
346
351
  }
347
352
 
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(