From 913b37661f23913ae33ebf88b125fc2686eb0e95 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:53:59 +0100 Subject: [PATCH] fix: send the quoted inline images along with replies and forwards --- src/lib/__tests__/reply-compose.test.ts | 22 ++++++++++++++++++++++ src/lib/reply-compose.ts | 25 +++++++++++++++++++------ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/lib/__tests__/reply-compose.test.ts b/src/lib/__tests__/reply-compose.test.ts index 0a5f4f68..80abf7a3 100644 --- a/src/lib/__tests__/reply-compose.test.ts +++ b/src/lib/__tests__/reply-compose.test.ts @@ -27,3 +27,25 @@ describe('replyComposeParams', () => { expect(replyComposeParams('reply', source, undefined, ' ')?.prefillBody).toBeUndefined(); }); }); + +describe('replyComposeParams: quoted inline images', () => { + const quotedImage = { blobId: 'b-img', type: 'image/png', name: 'logo.png', disposition: 'inline', cid: '' }; + const unusedInline = { blobId: 'b-sig', type: 'image/png', name: 'sig.png', disposition: 'inline', cid: 'sig@x' }; + const file = { blobId: 'b-pdf', type: 'application/pdf', name: 'report.pdf', disposition: 'attachment' }; + const withImage = { + ...source, + htmlBody: [{ partId: '2', type: 'text/html' }], + textBody: [{ partId: '1', type: 'text/plain' }], + bodyValues: { 1: { value: 'Hi' }, 2: { value: '

Hi

' } }, + attachments: [quotedImage, unusedInline, file], + } as unknown as Email; + + it('hands a reply the image its quote embeds and nothing else', () => { + expect(replyComposeParams('reply', withImage)?.replyTo?.attachments).toEqual([quotedImage]); + expect(replyComposeParams('reply', source)?.replyTo?.attachments).toBeUndefined(); + }); + + it('hands a forward the quoted image and the files, not unused inline parts', () => { + expect(replyComposeParams('forward', withImage)?.replyTo?.attachments).toEqual([quotedImage, file]); + }); +}); diff --git a/src/lib/reply-compose.ts b/src/lib/reply-compose.ts index 20fe3a3b..c328d619 100644 --- a/src/lib/reply-compose.ts +++ b/src/lib/reply-compose.ts @@ -1,6 +1,23 @@ -import type { Email } from '../api/types'; +import type { Attachment, Email } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; import { pickEmailBody, plainTextBody } from './email-body'; +import { extractCidRefs } from './email-html'; +import { stripMessageIdBrackets } from './email-threading'; + +/** + * The parts a reply or forward hands the composer. The inline images the + * quoted HTML embeds go along in both modes: the composer fetches them into + * the quote and attaches them again on send (it never shows them as chips). + * Without them a quoted `cid:` image went out pointing at a part the new + * message did not have. A forward also carries the real attachments. + */ +function carriedParts(mode: 'reply' | 'replyAll' | 'forward', source: Email, quoteHtml?: string): Attachment[] | undefined { + const quoted = new Set(quoteHtml ? extractCidRefs(quoteHtml) : []); + const isQuoted = (a: Attachment) => !!a.cid && quoted.has(stripMessageIdBrackets(a.cid)); + const parts = (source.attachments ?? []).filter((a) => + isQuoted(a) || (mode === 'forward' && !(a.disposition === 'inline' && a.cid))); + return parts.length > 0 || mode === 'forward' ? parts : undefined; +} /** * Composer params for a reply / reply-all / forward of `source`. Shared by @@ -42,11 +59,7 @@ export function replyComposeParams( // Threading needs the RFC Message-ID, never the JMAP object id (#234). messageId: source.messageId ?? undefined, references: source.references ?? undefined, - // Forward carries the original attachments as blob refs; cid-embedded - // inline images are already part of the quoted HTML. - attachments: mode === 'forward' - ? (source.attachments ?? []).filter((a) => !(a.disposition === 'inline' && a.cid)) - : undefined, + attachments: carriedParts(mode, source, quoteHtml), originalEmailId: source.id, jmapAccountId: ownerAccountId, },