diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 98b89989..90704e69 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -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, '&').replace(//g, '>').replace(/\n/g, '
')
: '');
const bodyHtml = wrap
@@ -314,7 +319,10 @@ export function EmailComposer({
const quoteHeader = mode === 'forward'
? `---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}
`
: `On ${date}, ${fromStr} wrote:
`;
- return `${prefix}${signatureBlock}
${quoteHeader}
${replyTo.htmlBody}
`;
+ // 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}
${quoteHeader}
${quotedHtml}
`;
}
if (replyTo.body) {
@@ -534,6 +542,76 @@ export function EmailComposer({
selectedIdentityId,
]);
+ // Hydrate inline images referenced by the quoted body (issue #163).
+ // `getInitialBody` rewrites `
` 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();
+ 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((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
? `${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}
`
: signatureIdentity?.textSignature
diff --git a/components/email/resizable-image.tsx b/components/email/resizable-image.tsx
index 4fdf9d44..a6e9e611 100644
--- a/components/email/resizable-image.tsx
+++ b/components/email/resizable-image.tsx
@@ -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 } : {}),
},
};
diff --git a/lib/__tests__/email-composer-utils.test.ts b/lib/__tests__/email-composer-utils.test.ts
index 5aecc21e..0daa17b4 100644
--- a/lib/__tests__/email-composer-utils.test.ts
+++ b/lib/__tests__/email-composer-utils.test.ts
@@ -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 = 'hi
';
+ expect(rewriteCidImagesForEditor(html)).toBe(html);
+ });
+
+ it("handles empty input", () => {
+ expect(rewriteCidImagesForEditor("")).toBe("");
+ });
+
+ it("rewrites a cid: src to placeholder + data-cid", () => {
+ const out = rewriteCidImagesForEditor(
+ '
'
+ );
+ 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(
+ '
'
+ );
+ expect(out).toContain('data-cid="kept"');
+ expect(out).not.toContain('data-cid="abc"');
+ });
+
+ it("leaves non-cid images alone", () => {
+ const out = rewriteCidImagesForEditor(
+ '
'
+ );
+ 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 = '
';
+ expect(replaceInlineImagePlaceholders(html, new Map())).toBe(html);
+ });
+
+ it("swaps the placeholder src to the data URL for matching cids", () => {
+ const html = `
`;
+ 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 = '
';
+ 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 =
+ '
';
+ 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 = `
`;
+ const out = replaceInlineImagePlaceholders(
+ html,
+ new Map([["abc", "data:image/png;base64,AAAA"]])
+ );
+ expect(out).toBe(html);
+ });
+});
diff --git a/lib/email-composer-utils.ts b/lib/email-composer-utils.ts
index 8141cc42..e41fdb8f 100644
--- a/lib/email-composer-utils.ts
+++ b/lib/email-composer-utils.ts
@@ -21,3 +21,59 @@ export function plainTextToComposerBody(text: string): string {
.map((paragraph) => `${escapeHtml(paragraph).replace(/\n/g, "
")}
`)
.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 `
` references into `
`
+ * 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(`${html}`, "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 `
` 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 {
+ if (!html || cidToDataUrl.size === 0) return html;
+ if (html.indexOf("data-cid") === -1) return html;
+ const doc = new DOMParser().parseFromString(`${html}`, "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;
+}