From d4f7ae522e2911afe074926904f768af0c7154ce Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:57:46 +0200 Subject: [PATCH] fix: use cid references for inline images #163 --- app/[locale]/page.tsx | 2 +- components/email/email-composer.tsx | 101 ++++++++++++++++++++++---- components/email/resizable-image.tsx | 5 ++ components/email/rich-text-editor.tsx | 19 +++-- lib/demo/demo-client.ts | 4 +- lib/jmap/client-interface.ts | 4 +- lib/jmap/client.ts | 12 +-- stores/email-store.ts | 2 +- 8 files changed, 115 insertions(+), 34 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index f389bc99..28b5ef11 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -644,7 +644,7 @@ export default function Home() { fromEmail?: string; fromName?: string; identityId?: string; - attachments?: Array<{ blobId: string; name: string; type: string; size: number }>; + attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; }) => { if (!client) return; diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index a16ef873..710f3ae1 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -6,7 +6,7 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react"; -import { cn, formatFileSize, formatDateTime } from "@/lib/utils"; +import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; import { sanitizeEmailHtml } from "@/lib/email-sanitization"; @@ -66,7 +66,7 @@ interface EmailComposerProps { fromEmail?: string; fromName?: string; identityId?: string; - attachments?: Array<{ blobId: string; name: string; type: string; size: number }>; + attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; }) => void | Promise; onClose?: () => void; onDiscardDraft?: (draftId: string) => void; @@ -202,6 +202,7 @@ export function EmailComposer({ const saveTimeoutRef = useRef(null); const lastSavedDataRef = useRef(""); const [attachments, setAttachments] = useState>([]); + const inlineImagesRef = useRef>([]); const fileInputRef = useRef(null); const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({}); const [shakeField, setShakeField] = useState(null); @@ -560,18 +561,38 @@ export function EmailComposer({ } }, [client, t]); - const handleImageUpload = useCallback((file: File): Promise => { - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = (e) => resolve((e.target?.result as string) ?? null); - reader.onerror = () => { - debug.error(`Failed to read inline image ${file.name}`); - toast.error(t('upload_failed', { filename: file.name })); - resolve(null); - }; - reader.readAsDataURL(file); - }); - }, [t]); + const handleImageUpload = useCallback(async ( + file: File, + ): Promise<{ src: string; cid: string } | null> => { + if (!client) return null; + try { + const readAsDataUrl = new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (e) => resolve((e.target?.result as string) ?? null); + reader.onerror = () => resolve(null); + reader.readAsDataURL(file); + }); + const [{ blobId }, dataUrl] = await Promise.all([ + client.uploadBlob(file), + readAsDataUrl, + ]); + if (!dataUrl) throw new Error('Failed to read image as data URL'); + const cid = `${generateUUID()}@webmail`; + inlineImagesRef.current.push({ + cid, + blobId, + type: file.type || 'application/octet-stream', + name: file.name, + size: file.size, + dataUrl, + }); + return { src: dataUrl, cid }; + } catch (error) { + debug.error(`Failed to upload inline image ${file.name}:`, error); + toast.error(t('upload_failed', { filename: file.name })); + return null; + } + }, [client, t]); const handleFileSelect = async (event: React.ChangeEvent) => { if (!event.target.files) return; @@ -751,6 +772,41 @@ export function EmailComposer({ return undefined; }; + // Rewrite data: URLs of dropped images (tagged with data-cid) into cid: + // references so recipient clients that strip data URIs can still render them. + const rewriteInlineImages = (html: string): { + html: string; + attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>; + } => { + const known = inlineImagesRef.current; + if (known.length === 0) return { html, attachments: [] }; + + const doc = new DOMParser().parseFromString(`${html}`, 'text/html'); + const used = new Map(); + + doc.querySelectorAll('img[data-cid]').forEach((img) => { + const cid = img.getAttribute('data-cid'); + if (!cid) return; + const entry = known.find((e) => e.cid === cid); + if (!entry) return; + img.setAttribute('src', `cid:${cid}`); + img.removeAttribute('data-cid'); + used.set(cid, entry); + }); + + return { + html: doc.body.innerHTML, + attachments: Array.from(used.values()).map((e) => ({ + blobId: e.blobId, + name: e.name, + type: e.type, + size: e.size, + disposition: 'inline' as const, + cid: e.cid, + })), + }; + }; + const handleSend = async (skipAttachmentCheck = false) => { const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); @@ -821,9 +877,11 @@ export function EmailComposer({ ? appendPlainTextSignature(body, currentIdentity) : appendPlainTextSignature(htmlToPlainText(body), currentIdentity); + const rewritten = plainTextMode ? null : rewriteInlineImages(body); const finalHtmlBody = plainTextMode ? undefined - : `
${body}
${buildSignatureHtml()}`; + : `
${rewritten!.html}
${buildSignatureHtml()}`; + const inlineAttachments = rewritten?.attachments ?? []; try { // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail @@ -865,6 +923,16 @@ export function EmailComposer({ content, }); } + for (const inline of inlineAttachments) { + if (!client) break; + const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type); + mimeAttachments.push({ + filename: inline.name, + contentType: inline.type, + content, + cid: inline.cid, + }); + } // 4. Build canonical MIME const mimeBytes = buildMimeMessage({ @@ -924,9 +992,10 @@ export function EmailComposer({ } else { // Standard JMAP send path // Collect uploaded attachment blobIds for the send request - const uploadedAttachments = attachments + const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments .filter(att => att.blobId && !att.uploading && !att.error) .map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type || 'application/octet-stream', size: att.file.size })); + uploadedAttachments.push(...inlineAttachments); await onSend?.({ to: toAddresses, diff --git a/components/email/resizable-image.tsx b/components/email/resizable-image.tsx index 309ef3f7..4fdf9d44 100644 --- a/components/email/resizable-image.tsx +++ b/components/email/resizable-image.tsx @@ -113,6 +113,11 @@ export const ResizableImage = Node.create({ alt: { default: null }, title: { default: null }, width: { default: null }, + cid: { + default: null, + parseHTML: (el) => el.getAttribute("data-cid"), + renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}), + }, }; }, diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index a6c966f3..a30a7c82 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -31,10 +31,15 @@ import { Heading2, } from "lucide-react"; +export interface InlineImageUpload { + src: string; + cid?: string; +} + interface RichTextEditorProps { content: string; onChange: (html: string) => void; - onImageUpload?: (file: File) => Promise; + onImageUpload?: (file: File) => Promise; placeholder?: string; className?: string; hasError?: boolean; @@ -120,11 +125,11 @@ export function RichTextEditor({ event.preventDefault(); event.stopPropagation(); for (const file of imageFiles) { - upload(file).then((url) => { - if (url) { + upload(file).then((result) => { + if (result) { const { state } = view; const pos = view.posAtCoords({ left: event.clientX, top: event.clientY }); - const node = state.schema.nodes.image.create({ src: url, alt: file.name }); + const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid }); const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node); view.dispatch(tr); } @@ -141,10 +146,10 @@ export function RichTextEditor({ if (imageFiles.length === 0) return false; event.preventDefault(); for (const file of imageFiles) { - upload(file).then((url) => { - if (url) { + upload(file).then((result) => { + if (result) { const { state } = view; - const node = state.schema.nodes.image.create({ src: url, alt: file.name }); + const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid }); const tr = state.tr.replaceSelectionWith(node); view.dispatch(tr); } diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index ab38623c..6a09f19e 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -317,7 +317,7 @@ export class DemoJMAPClient implements IJMAPClient { _identityId?: string, _fromEmail?: string, draftId?: string, - attachments?: Array<{ blobId: string; name: string; type: string; size: number }>, + attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, _fromName?: string, ): Promise { const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts'); @@ -365,7 +365,7 @@ export class DemoJMAPClient implements IJMAPClient { draftId?: string, _fromName?: string, htmlBody?: string, - attachments?: Array<{ blobId: string; name: string; type: string; size: number }>, + attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, ): Promise { // Remove draft if updating if (draftId) { diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 8b1a225b..34dc54d7 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -103,7 +103,7 @@ export interface IJMAPClient { identityId?: string, fromEmail?: string, draftId?: string, - attachments?: Array<{ blobId: string; name: string; type: string; size: number }>, + attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, fromName?: string, ): Promise; @@ -118,7 +118,7 @@ export interface IJMAPClient { draftId?: string, fromName?: string, htmlBody?: string, - attachments?: Array<{ blobId: string; name: string; type: string; size: number }>, + attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, ): Promise; sendImipReply(opts: { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index bf95fa4c..d8c77946 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1701,7 +1701,7 @@ export class JMAPClient implements IJMAPClient { identityId?: string, fromEmail?: string, draftId?: string, - attachments?: Array<{ blobId: string; name: string; type: string; size: number }>, + attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, fromName?: string ): Promise { const mailboxes = await this.getMailboxes(); @@ -1722,7 +1722,7 @@ export class JMAPClient implements IJMAPClient { mailboxIds: Record; bodyValues: Record; textBody: { partId: string }[]; - attachments?: { blobId: string; type: string; name: string; disposition: string }[]; + attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[]; } const emailData: EmailDraft = { @@ -1742,7 +1742,8 @@ export class JMAPClient implements IJMAPClient { blobId: att.blobId, type: att.type, name: att.name, - disposition: "attachment", + disposition: att.disposition ?? "attachment", + ...(att.cid ? { cid: att.cid } : {}), })); } @@ -1795,7 +1796,7 @@ export class JMAPClient implements IJMAPClient { draftId?: string, fromName?: string, htmlBody?: string, - attachments?: Array<{ blobId: string; name: string; type: string; size: number }> + attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> ): Promise { const emailId = `send-${Date.now()}`; const mailboxes = await this.getMailboxes(); @@ -1865,7 +1866,8 @@ export class JMAPClient implements IJMAPClient { blobId: att.blobId, type: att.type, name: att.name, - disposition: "attachment", + disposition: att.disposition ?? "attachment", + ...(att.cid ? { cid: att.cid } : {}), })); } diff --git a/stores/email-store.ts b/stores/email-store.ts index f124b282..157b367a 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -72,7 +72,7 @@ interface EmailStore { loadMoreEmails: (client: IJMAPClient) => Promise; fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise; fetchQuota: (client: IJMAPClient) => Promise; - sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number }>) => Promise; + sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>) => Promise; sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise; deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise;