fix: preview .eml attachments that carry attachments of their own

This commit is contained in:
Linus Rath
2026-10-16 18:48:26 +02:00
parent 7c534301ee
commit 3aca2d80c0
2 changed files with 136 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
diff --git a/node_modules/postal-mime/src/base64-decoder.js b/node_modules/postal-mime/src/base64-decoder.js
index 93c2aea..ba6e3f1 100644
--- a/node_modules/postal-mime/src/base64-decoder.js
+++ b/node_modules/postal-mime/src/base64-decoder.js
@@ -1,4 +1,4 @@
-import { decodeBase64, blobToArrayBuffer } from './decode-strings.js';
+import { decodeBase64, concatChunks } from './decode-strings.js';
export default class Base64Decoder {
constructor(opts) {
@@ -49,6 +49,6 @@ export default class Base64Decoder {
finalize() {
this.flushRemainder();
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
diff --git a/node_modules/postal-mime/src/decode-strings.js b/node_modules/postal-mime/src/decode-strings.js
index a5e058e..f255e8d 100644
--- a/node_modules/postal-mime/src/decode-strings.js
+++ b/node_modules/postal-mime/src/decode-strings.js
@@ -145,6 +145,23 @@ export function getDecoder(charset) {
* @param {Blob} blob Blob to convert
* @returns {ArrayBuffer} Converted value
*/
+// React Native's Blob cannot be built from ArrayBuffers or typed arrays, so
+// the decoders join their chunks here instead of through a Blob.
+export function concatChunks(chunks) {
+ const parts = chunks.map(chunk => {
+ if (typeof chunk === 'string') return textEncoder.encode(chunk);
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
+ });
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.byteLength, 0));
+ let pos = 0;
+ for (const part of parts) {
+ out.set(part, pos);
+ pos += part.byteLength;
+ }
+ return out.buffer;
+}
+
export async function blobToArrayBuffer(blob) {
if ('arrayBuffer' in blob) {
return await blob.arrayBuffer();
diff --git a/node_modules/postal-mime/src/pass-through-decoder.js b/node_modules/postal-mime/src/pass-through-decoder.js
index ad1c222..1e1d012 100644
--- a/node_modules/postal-mime/src/pass-through-decoder.js
+++ b/node_modules/postal-mime/src/pass-through-decoder.js
@@ -1,4 +1,4 @@
-import { blobToArrayBuffer } from './decode-strings.js';
+import { concatChunks } from './decode-strings.js';
export default class PassThroughDecoder {
constructor() {
@@ -12,6 +12,6 @@ export default class PassThroughDecoder {
finalize() {
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
diff --git a/node_modules/postal-mime/src/qp-decoder.js b/node_modules/postal-mime/src/qp-decoder.js
index adee72c..3973673 100644
--- a/node_modules/postal-mime/src/qp-decoder.js
+++ b/node_modules/postal-mime/src/qp-decoder.js
@@ -1,4 +1,4 @@
-import { blobToArrayBuffer, hexNibble } from './decode-strings.js';
+import { concatChunks, hexNibble } from './decode-strings.js';
const CHR_EQUALS = 0x3d;
const CHR_LF = 0x0a;
@@ -83,6 +83,6 @@ export default class QPDecoder {
this.flushBuffer();
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import PostalMime from 'postal-mime';
// React Native's Blob only takes strings and other Blobs; building one from an
// ArrayBuffer or typed array throws. postal-mime used to join every decoded
// part through such a Blob, so an .eml with an attachment could not be
// previewed on the device (patches/postal-mime+3.0.0.patch).
class ReactNativeBlob {
constructor(parts: unknown[] = []) {
if (parts.some((p) => p instanceof ArrayBuffer || ArrayBuffer.isView(p))) {
throw new Error("Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' are not supported");
}
}
}
const MESSAGE = [
'From: Carol <carol@example.org>',
'Subject: With parts',
'MIME-Version: 1.0',
'Content-Type: multipart/mixed; boundary="b1"',
'',
'--b1',
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: quoted-printable',
'',
'Gr=C3=BC=C3=9Fe',
'--b1',
'Content-Type: application/octet-stream; name="a.bin"',
'Content-Transfer-Encoding: base64',
'Content-Disposition: attachment; filename="a.bin"',
'',
'AAECAwQF',
'--b1',
'Content-Type: text/plain; name="inner.txt"',
'Content-Disposition: attachment; filename="inner.txt"',
'',
'Inner text.',
'--b1--',
'',
].join('\r\n');
describe('postal-mime under React Native', () => {
const realBlob = globalThis.Blob;
beforeEach(() => { (globalThis as { Blob: unknown }).Blob = ReactNativeBlob; });
afterEach(() => { globalThis.Blob = realBlob; });
it('decodes base64, quoted-printable and plain parts without a Blob', async () => {
const parsed = await PostalMime.parse(new TextEncoder().encode(MESSAGE), { attachmentEncoding: 'arraybuffer' });
expect(parsed.text?.trim()).toBe('Grüße');
const [bin, txt] = parsed.attachments;
expect(Array.from(new Uint8Array(bin.content as ArrayBuffer))).toEqual([0, 1, 2, 3, 4, 5]);
expect(new TextDecoder().decode(txt.content as ArrayBuffer).trim()).toBe('Inner text.');
});
});