postal-mime 2.4.3 → 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,19 @@
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
+
10
+ ## [2.4.4](https://github.com/postalsys/postal-mime/compare/v2.4.3...v2.4.4) (2025-06-26)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **TextDecoder:** Fall back to windows-1252 for an unknown charset instead of throwing ([d5b917d](https://github.com/postalsys/postal-mime/commit/d5b917d5b09fab9183733cd76ebdc896467ae31e))
16
+
3
17
  ## [2.4.3](https://github.com/postalsys/postal-mime/compare/v2.4.2...v2.4.3) (2025-01-24)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postal-mime",
3
- "version": "2.4.3",
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",
@@ -28,11 +30,12 @@
28
30
  "author": "Andris Reinman",
29
31
  "license": "MIT-0",
30
32
  "devDependencies": {
31
- "@types/node": "22.10.10",
33
+ "@types/node": "24.0.4",
32
34
  "cross-blob": "3.0.2",
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
  }
@@ -44,7 +44,15 @@ export function decodeBase64(base64) {
44
44
 
45
45
  export function getDecoder(charset) {
46
46
  charset = charset || 'utf8';
47
- return new TextDecoder(charset);
47
+ let decoder;
48
+
49
+ try {
50
+ decoder = new TextDecoder(charset);
51
+ } catch (err) {
52
+ decoder = new TextDecoder('windows-1252');
53
+ }
54
+
55
+ return decoder;
48
56
  }
49
57
 
50
58
  /**
@@ -73,7 +81,11 @@ export async function blobToArrayBuffer(blob) {
73
81
  }
74
82
 
75
83
  export function getHex(c) {
76
- 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
+ ) {
77
89
  return String.fromCharCode(c);
78
90
  }
79
91
  return false;
@@ -144,36 +156,44 @@ export function decodeWords(str) {
144
156
  let result = (str || '')
145
157
  .toString()
146
158
  // find base64 words that can be joined
147
- .replace(/(=\?([^?]+)\?[Bb]\?([^?]*)\?=)\s*(?==\?([^?]+)\?[Bb]\?[^?]*\?=)/g, (match, left, chLeft, encodedLeftStr, chRight) => {
148
- 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
+
149
171
  return match;
150
172
  }
151
- // only mark b64 chunks to be joined if charsets match and left side does not end with =
152
- if (chLeft === chRight && encodedLeftStr.length % 4 === 0 && !/=$/.test(encodedLeftStr)) {
153
- // set a joiner marker
154
- return left + '__\x00JOIN\x00__';
155
- }
156
-
157
- return match;
158
- })
173
+ )
159
174
  // find QP words that can be joined
160
- .replace(/(=\?([^?]+)\?[Qq]\?[^?]*\?=)\s*(?==\?([^?]+)\?[Qq]\?[^?]*\?=)/g, (match, left, chLeft, chRight) => {
161
- 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
+ }
162
186
  return match;
163
187
  }
164
- // only mark QP chunks to be joined if charsets match
165
- if (chLeft === chRight) {
166
- // set a joiner marker
167
- return left + '__\x00JOIN\x00__';
168
- }
169
- return match;
170
- })
188
+ )
171
189
  // join base64 encoded words
172
190
  .replace(/(\?=)?__\x00JOIN\x00__(=\?([^?]+)\?[QqBb]\?)?/g, '')
173
191
  // remove spaces between mime encoded words
174
192
  .replace(/(=\?[^?]+\?[QqBb]\?[^?]*\?=)\s+(?==\?[^?]+\?[QqBb]\?[^?]*\?=)/g, '$1')
175
193
  // decode words
176
- .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
+ );
177
197
 
178
198
  if (joinString && result.indexOf('\ufffd') >= 0) {
179
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(