From 3aca2d80c04ceb50eb9c424452b464c30fbc5865 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:46:16 +0200 Subject: [PATCH] fix: preview .eml attachments that carry attachments of their own --- patches/postal-mime+3.0.0.patch | 82 ++++++++++++++++++++++++ src/lib/__tests__/postal-mime-rn.test.ts | 54 ++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 patches/postal-mime+3.0.0.patch create mode 100644 src/lib/__tests__/postal-mime-rn.test.ts diff --git a/patches/postal-mime+3.0.0.patch b/patches/postal-mime+3.0.0.patch new file mode 100644 index 00000000..a9a976d3 --- /dev/null +++ b/patches/postal-mime+3.0.0.patch @@ -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); + } + } diff --git a/src/lib/__tests__/postal-mime-rn.test.ts b/src/lib/__tests__/postal-mime-rn.test.ts new file mode 100644 index 00000000..03f60e9c --- /dev/null +++ b/src/lib/__tests__/postal-mime-rn.test.ts @@ -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 ', + '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.'); + }); +});