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.
- package/.prettierignore +9 -0
- package/.prettierrc.json +10 -0
- package/CHANGELOG.md +14 -0
- package/package.json +7 -4
- package/src/decode-strings.js +42 -22
- package/src/postal-mime.js +8 -3
- package/src/qp-decoder.js +36 -9
- package/src/text-format.js +24 -7
package/.prettierignore
ADDED
package/.prettierrc.json
ADDED
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
|
+
"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": "
|
|
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
|
}
|
package/src/decode-strings.js
CHANGED
|
@@ -44,7 +44,15 @@ export function decodeBase64(base64) {
|
|
|
44
44
|
|
|
45
45
|
export function getDecoder(charset) {
|
|
46
46
|
charset = charset || 'utf8';
|
|
47
|
-
|
|
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 (
|
|
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(
|
|
148
|
-
|
|
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
|
-
|
|
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(
|
|
161
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
package/src/postal-mime.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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(
|
|
101
|
+
let partialEnding = str.match(PARTIAL_QP_ENDING_REGEX);
|
|
75
102
|
if (partialEnding) {
|
|
76
103
|
if (partialEnding.index === 0) {
|
|
77
104
|
this.remainder = str;
|
package/src/text-format.js
CHANGED
|
@@ -177,7 +177,10 @@ function foldLines(str, lineLength, afterSpace) {
|
|
|
177
177
|
result += line;
|
|
178
178
|
pos += line.length;
|
|
179
179
|
continue;
|
|
180
|
-
} else if (
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|