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( + '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( + '' + ); + 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; +}