From 718fd00c6d52204b80b1b285f27a6ed486648fe0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:25:33 +0100 Subject: [PATCH] fix: hide the pgp/mime control parts of an encrypted message from its attachments --- src/components/email/AttachmentChips.tsx | 7 ++- src/components/email/MessageContent.tsx | 7 +++ src/lib/__tests__/pgp-mime-parts.test.ts | 62 ++++++++++++++++++++++++ src/lib/pgp-mime-parts.ts | 60 +++++++++++++++++++++++ 4 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/pgp-mime-parts.test.ts create mode 100644 src/lib/pgp-mime-parts.ts diff --git a/src/components/email/AttachmentChips.tsx b/src/components/email/AttachmentChips.tsx index 88599955..b8055e4b 100644 --- a/src/components/email/AttachmentChips.tsx +++ b/src/components/email/AttachmentChips.tsx @@ -31,6 +31,8 @@ interface Props { tnefUnpacked?: boolean; /** Parts extracted client-side (TNEF / embedded message). */ extracted?: ExtractedAttachment[]; + /** Server-listed parts to leave out (a decrypted PGP/MIME message's control parts). */ + hidePart?: (att: Attachment) => boolean; } type Item = @@ -45,7 +47,7 @@ const MAX_BUNDLE_BYTES = 100 * 1024 * 1024; * save), long-press opens Preview / Open / Save / Share, and "Download all" * bundles every part into a zip named by the export template. */ -export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tnefUnpacked, extracted }: Props) { +export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tnefUnpacked, extracted, hidePart }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -76,6 +78,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne const items = React.useMemo(() => { const blobs = visibleAttachments(email, { hideInlineImageAttachments, calendarBannerShown, tnefUnpacked }) + .filter((att) => !hidePart?.(att)) .map((att, i) => ({ key: att.blobId || `att-${i}`, kind: 'blob', @@ -93,7 +96,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne size: part.size, })); return [...blobs, ...extra]; - }, [email, hideInlineImageAttachments, calendarBannerShown, tnefUnpacked, extracted]); + }, [email, hideInlineImageAttachments, calendarBannerShown, tnefUnpacked, extracted, hidePart]); if (items.length === 0) return null; diff --git a/src/components/email/MessageContent.tsx b/src/components/email/MessageContent.tsx index 95f64b38..e1e0b8fc 100644 --- a/src/components/email/MessageContent.tsx +++ b/src/components/email/MessageContent.tsx @@ -16,6 +16,7 @@ import { useSecureMessage } from './use-secure-message'; import { SecureMessageBanner } from './SecureMessageBanner'; import { deriveHeaderInfo } from '../../lib/email-headers'; import { calendarBannerShownFor } from '../../lib/attachment-display'; +import { hidesPgpMimeControlParts, pgpMimeControlParts } from '../../lib/pgp-mime-parts'; export interface MessageContentProps { email: Email; @@ -82,6 +83,11 @@ export function MessageContent({ () => (secure.result?.status.encrypted && secure.result.attachments.length ? [...secure.result.attachments, ...unwrap.extracted] : unwrap.extracted), [secure.result, unwrap.extracted], ); + // A PGP/MIME message's version part and encrypted payload are no + // attachments of the decrypted message (nor of a locked one). + const hideControlParts = hidesPgpMimeControlParts({ loading: secure.loading, status: secure.result?.status ?? null }); + const controlPart = React.useMemo(() => pgpMimeControlParts(email), [email]); + const hidePart = hideControlParts ? controlPart : undefined; const calendarBannerShown = calendarBannerShownFor(email, calendarParsing); const from = email.from?.[0]; @@ -92,6 +98,7 @@ export function MessageContent({ calendarBannerShown={calendarBannerShown} tnefUnpacked={unwrap.tnefUnpacked} extracted={extracted} + hidePart={hidePart} /> ); diff --git a/src/lib/__tests__/pgp-mime-parts.test.ts b/src/lib/__tests__/pgp-mime-parts.test.ts new file mode 100644 index 00000000..fdb04e95 --- /dev/null +++ b/src/lib/__tests__/pgp-mime-parts.test.ts @@ -0,0 +1,62 @@ +// A decrypted PGP/MIME message still listed its control parts as attachments: +// "Attachment.pgp-encrypted" (the version part) and "encrypted.asc". +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { Email } from '../../api/types'; +import type { SecureStatus } from '../crypto/secure-message'; +import { hidesPgpMimeControlParts, pgpMimeControlParts } from '../pgp-mime-parts'; + +const version = { partId: '1', blobId: 'b-version', type: 'application/pgp-encrypted', size: 12 }; +const payload = { partId: '2', blobId: 'b-payload', type: 'application/octet-stream', name: 'encrypted.asc', size: 900 }; +const pgpMime = { + bodyStructure: { type: 'multipart/encrypted', subParts: [version, payload] }, + attachments: [version, payload], +} as unknown as Email; + +const plainWithPdf = { + bodyStructure: { + type: 'multipart/mixed', + subParts: [{ partId: '1', type: 'text/plain' }, { partId: '2', blobId: 'b-pdf', type: 'application/pdf' }], + }, + attachments: [{ partId: '2', blobId: 'b-pdf', type: 'application/pdf' }], +} as unknown as Email; + +const status = (s: Partial): SecureStatus => ({ + kind: 'pgp', encrypted: true, decrypted: false, signature: 'none', ...s, +}); + +describe('PGP/MIME control parts', () => { + it('are the parts of the multipart/encrypted', () => { + const isControl = pgpMimeControlParts(pgpMime); + expect(pgpMime.attachments!.filter((a) => !isControl(a))).toEqual([]); + }); + + it('are found by type when the structure is missing', () => { + const isControl = pgpMimeControlParts({ attachments: [version, payload] } as unknown as Email); + expect(isControl(version) && isControl(payload)).toBe(true); + }); + + it('leave other messages alone', () => { + const isControl = pgpMimeControlParts(plainWithPdf); + expect(plainWithPdf.attachments!.filter((a) => !isControl(a))).toHaveLength(1); + }); + + it('are hidden once decrypted, while opening and while a key is locked', () => { + expect(hidesPgpMimeControlParts({ loading: false, status: status({ decrypted: true }) })).toBe(true); + expect(hidesPgpMimeControlParts({ loading: true, status: null })).toBe(true); + expect(hidesPgpMimeControlParts({ loading: false, status: status({ lockedKeyIds: ['k1'] }) })).toBe(true); + }); + + it('stay when the message cannot be decrypted, so the payload can be saved', () => { + expect(hidesPgpMimeControlParts({ loading: false, status: status({ noKey: true }) })).toBe(false); + expect(hidesPgpMimeControlParts({ loading: false, status: null })).toBe(false); + }); + + it('are filtered out of the reader chips', () => { + const content = fs.readFileSync(path.resolve(__dirname, '../../components/email/MessageContent.tsx'), 'utf8'); + expect(content).toMatch(/hidePart=\{hidePart\}/); + const chips = fs.readFileSync(path.resolve(__dirname, '../../components/email/AttachmentChips.tsx'), 'utf8'); + expect(chips).toMatch(/\.filter\(\(att\) => !hidePart\?\.\(att\)\)/); + }); +}); diff --git a/src/lib/pgp-mime-parts.ts b/src/lib/pgp-mime-parts.ts new file mode 100644 index 00000000..e32ef4d2 --- /dev/null +++ b/src/lib/pgp-mime-parts.ts @@ -0,0 +1,60 @@ +// A PGP/MIME message (RFC 3156) is a multipart/encrypted with two parts: an +// application/pgp-encrypted version part and the encrypted payload, usually +// named encrypted.asc. The server lists both as attachments, so a decrypted +// message still showed "Attachment.pgp-encrypted" and "encrypted.asc" chips +// beside the decrypted body's own attachments. As in the webmail OpenPGP +// plugin (repos/plugins/bulwark-gpg/src/index.js onRenderEmailBody, which +// replaces the attachments with the decrypted ones), they are left out once +// the message is decrypted, and while it waits for a key to be unlocked (the +// banner offers Unlock). When it can't be decrypted at all they stay, so the +// payload can still be saved and opened elsewhere. + +import type { BodyPart, Email } from '../api/types'; +import type { SecureStatus } from './crypto/secure-message'; + +type PartLike = Pick & { partId?: string | null; blobId?: string | null; subParts?: PartLike[] }; + +/** An attachment as the server lists it (partId is there, though not in the type). */ +export interface AttachmentRef { + partId?: string | null; + blobId?: string | null; + type?: string | null; +} + +const base = (type?: string | null) => (type || '').split(';')[0].trim().toLowerCase(); + +/** The attachments that are a PGP/MIME message's control parts, by blob id or part id. */ +export function pgpMimeControlParts( + email: Pick, +): (att: AttachmentRef) => boolean { + const ids = new Set(); + const collect = (part: PartLike) => { + if (part.partId) ids.add(`p:${part.partId}`); + if (part.blobId) ids.add(`b:${part.blobId}`); + part.subParts?.forEach(collect); + }; + const walk = (part: PartLike | undefined) => { + if (!part) return; + if (base(part.type) === 'multipart/encrypted') { + part.subParts?.forEach(collect); + return; + } + part.subParts?.forEach(walk); + }; + walk(email.bodyStructure as PartLike | undefined); + if (ids.size > 0) { + return (att) => (!!att.partId && ids.has(`p:${att.partId}`)) || (!!att.blobId && ids.has(`b:${att.blobId}`)); + } + // Without the structure: the version part and the payload beside it. + const all = email.attachments ?? []; + if (!all.some((a) => base(a.type) === 'application/pgp-encrypted')) return () => false; + return (att) => base(att.type) === 'application/pgp-encrypted' || base(att.type) === 'application/octet-stream'; +} + +/** Whether the reader leaves the control parts out: decrypted, being opened, or waiting for an unlock. */ +export function hidesPgpMimeControlParts(secure: { loading: boolean; status: SecureStatus | null }): boolean { + if (secure.loading) return true; + const status = secure.status; + if (!status || status.kind !== 'pgp' || !status.encrypted) return false; + return status.decrypted || (status.lockedKeyIds?.length ?? 0) > 0; +}