fix: use cid references for inline images #163
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
onClose?: () => void;
|
||||
onDiscardDraft?: (draftId: string) => void;
|
||||
@@ -202,6 +202,7 @@ export function EmailComposer({
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSavedDataRef = useRef<string>("");
|
||||
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
|
||||
const inlineImagesRef = useRef<Array<{ cid: string; blobId: string; type: string; name: string; size: number; dataUrl: string }>>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
||||
const [shakeField, setShakeField] = useState<string | null>(null);
|
||||
@@ -560,18 +561,38 @@ export function EmailComposer({
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
|
||||
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<string | null>((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<HTMLInputElement>) => {
|
||||
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(`<body>${html}</body>`, 'text/html');
|
||||
const used = new Map<string, typeof known[number]>();
|
||||
|
||||
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
|
||||
: `<div>${body}</div>${buildSignatureHtml()}`;
|
||||
: `<div>${rewritten!.html}</div>${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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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<string | null>;
|
||||
onImageUpload?: (file: File) => Promise<InlineImageUpload | null>;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
// Remove draft if updating
|
||||
if (draftId) {
|
||||
|
||||
@@ -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<string>;
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
sendImipReply(opts: {
|
||||
|
||||
+7
-5
@@ -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<string> {
|
||||
const mailboxes = await this.getMailboxes();
|
||||
@@ -1722,7 +1722,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
mailboxIds: Record<string, boolean>;
|
||||
bodyValues: Record<string, { value: string }>;
|
||||
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<void> {
|
||||
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 } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ interface EmailStore {
|
||||
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
||||
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
||||
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
||||
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<void>;
|
||||
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<void>;
|
||||
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
|
||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user