fix: preserve inline images when replying #163

This commit is contained in:
Linus Rath
2026-05-22 12:06:55 +02:00
parent 704a259432
commit ac4a89120d
4 changed files with 236 additions and 4 deletions
+80 -2
View File
@@ -35,6 +35,10 @@ import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
} from "@/lib/email-composer-utils";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
@@ -300,7 +304,8 @@ export function EmailComposer({
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const wrap = replyTo.quoteWrapInBlockquote !== false;
const originalHtml = replyTo.htmlBody
?? (replyTo.body
? rewriteCidImagesForEditor(replyTo.htmlBody)
: (replyTo.body
? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
: '');
const bodyHtml = wrap
@@ -314,7 +319,10 @@ export function EmailComposer({
const quoteHeader = mode === 'forward'
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
: `On ${date}, ${fromStr} wrote:<br>`;
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
// cid: image refs are rewritten so they render in the editor (browsers
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedHtml}</blockquote>`;
}
if (replyTo.body) {
@@ -534,6 +542,76 @@ export function EmailComposer({
selectedIdentityId,
]);
// Hydrate inline images referenced by the quoted body (issue #163).
// `getInitialBody` rewrites `<img src="cid:xxx">` to placeholder src +
// data-cid; here we (1) register each inline attachment in inlineImagesRef
// so the send path re-attaches the blob with the right cid, and (2) fetch
// each blob as a data URL and swap it into the body so the editor actually
// shows the image instead of a blank placeholder.
useEffect(() => {
if (plainTextMode) return;
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
if (!composerClient || !replyTo?.attachments?.length) return;
const inlineAtts = replyTo.attachments.filter((att) =>
att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')
);
if (inlineAtts.length === 0) return;
// Seed the ref synchronously so a fast Send still attaches the right blobs
// even if the FileReader work below hasn't resolved yet.
for (const att of inlineAtts) {
if (!att.cid) continue;
if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue;
inlineImagesRef.current.push({
cid: att.cid,
blobId: att.blobId,
type: att.type,
name: att.name || 'inline',
size: att.size,
dataUrl: '',
});
}
let cancelled = false;
(async () => {
const updates = new Map<string, string>();
for (const att of inlineAtts) {
if (!att.cid) continue;
try {
const buffer = await composerClient.fetchBlobArrayBuffer(
att.blobId,
att.name || 'inline',
att.type,
);
if (cancelled) return;
const blob = new Blob([buffer], { type: att.type });
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
if (cancelled) return;
const entry = inlineImagesRef.current.find((e) => e.cid === att.cid);
if (entry) entry.dataUrl = dataUrl;
updates.set(att.cid, dataUrl);
} catch (err) {
debug.error('Failed to load inline image for compose', err);
}
}
if (cancelled || updates.size === 0) return;
setBody((prev) => replaceInlineImagePlaceholders(prev, updates));
})();
return () => {
cancelled = true;
};
// We deliberately hydrate once per composer open - subsequent replyTo
// object identity churn from parent renders shouldn't refetch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [composerClient, plainTextMode, mode]);
const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature
+11 -1
View File
@@ -115,7 +115,17 @@ export const ResizableImage = Node.create({
width: { default: null },
cid: {
default: null,
parseHTML: (el) => el.getAttribute("data-cid"),
parseHTML: (el) => {
const dataCid = el.getAttribute("data-cid");
if (dataCid) return dataCid;
// Fall back to deriving the cid from `src="cid:xxx"` so inline
// image refs survive editor round-trips even when data-cid was
// never set (defensive — the composer normally pre-rewrites
// quoted-body cid: refs into data-cid).
const src = el.getAttribute("src") || "";
if (/^cid:/i.test(src)) return src.slice(4) || null;
return null;
},
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
},
};
+89 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { plainTextToComposerBody } from "../email-composer-utils";
import {
plainTextToComposerBody,
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
INLINE_IMAGE_PLACEHOLDER,
} from "../email-composer-utils";
describe("plainTextToComposerBody", () => {
it("returns an empty string for empty input", () => {
@@ -24,3 +29,86 @@ describe("plainTextToComposerBody", () => {
);
});
});
describe("rewriteCidImagesForEditor", () => {
it("returns input unchanged when no cid: refs are present", () => {
const html = '<p>hi</p><img src="https://example.com/x.png">';
expect(rewriteCidImagesForEditor(html)).toBe(html);
});
it("handles empty input", () => {
expect(rewriteCidImagesForEditor("")).toBe("");
});
it("rewrites a cid: src to placeholder + data-cid", () => {
const out = rewriteCidImagesForEditor(
'<img src="cid:abc@x" alt="logo">'
);
expect(out).toContain('data-cid="abc@x"');
expect(out).toContain(`src="${INLINE_IMAGE_PLACEHOLDER}"`);
expect(out).toContain('alt="logo"');
expect(out).not.toContain('src="cid:');
});
it("preserves an existing data-cid attribute", () => {
const out = rewriteCidImagesForEditor(
'<img src="cid:abc" data-cid="kept">'
);
expect(out).toContain('data-cid="kept"');
expect(out).not.toContain('data-cid="abc"');
});
it("leaves non-cid images alone", () => {
const out = rewriteCidImagesForEditor(
'<img src="https://example.com/x.png"><img src="cid:y">'
);
expect(out).toContain('src="https://example.com/x.png"');
expect(out).toContain('data-cid="y"');
});
});
describe("replaceInlineImagePlaceholders", () => {
it("returns input unchanged when the map is empty", () => {
const html = '<img src="..." data-cid="x">';
expect(replaceInlineImagePlaceholders(html, new Map())).toBe(html);
});
it("swaps the placeholder src to the data URL for matching cids", () => {
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="abc">`;
const out = replaceInlineImagePlaceholders(
html,
new Map([["abc", "data:image/png;base64,AAAA"]])
);
expect(out).toContain('src="data:image/png;base64,AAAA"');
expect(out).toContain('data-cid="abc"');
});
it("also rewrites raw cid: src refs that lack a placeholder", () => {
const html = '<img src="cid:abc" data-cid="abc">';
const out = replaceInlineImagePlaceholders(
html,
new Map([["abc", "data:image/png;base64,AAAA"]])
);
expect(out).toContain('src="data:image/png;base64,AAAA"');
});
it("does not overwrite images the user has re-pointed away from the cid", () => {
const html =
'<img src="https://example.com/other.png" data-cid="abc">';
const out = replaceInlineImagePlaceholders(
html,
new Map([["abc", "data:image/png;base64,AAAA"]])
);
expect(out).toContain('src="https://example.com/other.png"');
expect(out).not.toContain("data:image/png;base64,AAAA");
});
it("leaves unknown cids untouched", () => {
const html = `<img src="${INLINE_IMAGE_PLACEHOLDER}" data-cid="missing">`;
const out = replaceInlineImagePlaceholders(
html,
new Map([["abc", "data:image/png;base64,AAAA"]])
);
expect(out).toBe(html);
});
});
+56
View File
@@ -21,3 +21,59 @@ export function plainTextToComposerBody(text: string): string {
.map((paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`)
.join("");
}
// Transparent 1x1 GIF used as a stand-in src while the real inline image is
// being fetched from JMAP. Browsers cannot render `cid:` URLs directly, so
// without this swap the editor would show a broken-image icon (issue #163).
export const INLINE_IMAGE_PLACEHOLDER =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
/**
* Rewrites `<img src="cid:xxx">` references into `<img src="<placeholder>" data-cid="xxx">`
* so TipTap can render the editor (the original cid: URL would 404) while still
* carrying the cid through edits. The placeholder is swapped to the actual
* image data once the corresponding inline blob has been fetched.
*/
export function rewriteCidImagesForEditor(html: string): string {
if (!html || html.indexOf("cid:") === -1) return html;
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
let touched = false;
doc.querySelectorAll("img").forEach((img) => {
const src = img.getAttribute("src") || "";
if (!/^cid:/i.test(src)) return;
const cid = src.slice(4);
if (!cid) return;
if (!img.getAttribute("data-cid")) {
img.setAttribute("data-cid", cid);
}
img.setAttribute("src", INLINE_IMAGE_PLACEHOLDER);
touched = true;
});
return touched ? doc.body.innerHTML : html;
}
/**
* Replaces the placeholder src on `<img data-cid="...">` elements with the
* resolved data URL once the inline blob has been fetched. Leaves images
* whose src has been edited away from the placeholder/cid alone.
*/
export function replaceInlineImagePlaceholders(
html: string,
cidToDataUrl: Map<string, string>
): string {
if (!html || cidToDataUrl.size === 0) return html;
if (html.indexOf("data-cid") === -1) return html;
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
let changed = false;
doc.querySelectorAll("img[data-cid]").forEach((img) => {
const cid = img.getAttribute("data-cid");
if (!cid) return;
const dataUrl = cidToDataUrl.get(cid);
if (!dataUrl) return;
const currentSrc = img.getAttribute("src") || "";
if (currentSrc !== INLINE_IMAGE_PLACEHOLDER && !/^cid:/i.test(currentSrc)) return;
img.setAttribute("src", dataUrl);
changed = true;
});
return changed ? doc.body.innerHTML : html;
}