fix: hide the pgp/mime control parts of an encrypted message from its attachments

This commit is contained in:
Linus Rath
2026-10-31 20:25:33 +01:00
parent cb0f50198e
commit 718fd00c6d
4 changed files with 134 additions and 2 deletions
+5 -2
View File
@@ -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<Item[]>(() => {
const blobs = visibleAttachments(email, { hideInlineImageAttachments, calendarBannerShown, tnefUnpacked })
.filter((att) => !hidePart?.(att))
.map<Item>((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;
+7
View File
@@ -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}
/>
);
+62
View File
@@ -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>): 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\)\)/);
});
});
+60
View File
@@ -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<BodyPart, 'type'> & { 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<Email, 'bodyStructure' | 'attachments'>,
): (att: AttachmentRef) => boolean {
const ids = new Set<string>();
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;
}