fix: use cid references for inline images #163
This commit is contained in:
@@ -644,7 +644,7 @@ export default function Home() {
|
|||||||
fromEmail?: string;
|
fromEmail?: string;
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: 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;
|
if (!client) return;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
|
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 { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||||
@@ -66,7 +66,7 @@ interface EmailComposerProps {
|
|||||||
fromEmail?: string;
|
fromEmail?: string;
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: 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>;
|
}) => void | Promise<void>;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onDiscardDraft?: (draftId: string) => void;
|
onDiscardDraft?: (draftId: string) => void;
|
||||||
@@ -202,6 +202,7 @@ export function EmailComposer({
|
|||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastSavedDataRef = useRef<string>("");
|
const lastSavedDataRef = useRef<string>("");
|
||||||
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
|
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
||||||
const [shakeField, setShakeField] = useState<string | null>(null);
|
const [shakeField, setShakeField] = useState<string | null>(null);
|
||||||
@@ -560,18 +561,38 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
|
const handleImageUpload = useCallback(async (
|
||||||
return new Promise((resolve) => {
|
file: File,
|
||||||
const reader = new FileReader();
|
): Promise<{ src: string; cid: string } | null> => {
|
||||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
if (!client) return null;
|
||||||
reader.onerror = () => {
|
try {
|
||||||
debug.error(`Failed to read inline image ${file.name}`);
|
const readAsDataUrl = new Promise<string | null>((resolve) => {
|
||||||
toast.error(t('upload_failed', { filename: file.name }));
|
const reader = new FileReader();
|
||||||
resolve(null);
|
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||||
};
|
reader.onerror = () => resolve(null);
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
}, [t]);
|
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>) => {
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!event.target.files) return;
|
if (!event.target.files) return;
|
||||||
@@ -751,6 +772,41 @@ export function EmailComposer({
|
|||||||
return undefined;
|
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 handleSend = async (skipAttachmentCheck = false) => {
|
||||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bccAddresses = bcc.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(body, currentIdentity)
|
||||||
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||||
|
|
||||||
|
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||||
const finalHtmlBody = plainTextMode
|
const finalHtmlBody = plainTextMode
|
||||||
? undefined
|
? undefined
|
||||||
: `<div>${body}</div>${buildSignatureHtml()}`;
|
: `<div>${rewritten!.html}</div>${buildSignatureHtml()}`;
|
||||||
|
const inlineAttachments = rewritten?.attachments ?? [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||||
@@ -865,6 +923,16 @@ export function EmailComposer({
|
|||||||
content,
|
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
|
// 4. Build canonical MIME
|
||||||
const mimeBytes = buildMimeMessage({
|
const mimeBytes = buildMimeMessage({
|
||||||
@@ -924,9 +992,10 @@ export function EmailComposer({
|
|||||||
} else {
|
} else {
|
||||||
// Standard JMAP send path
|
// Standard JMAP send path
|
||||||
// Collect uploaded attachment blobIds for the send request
|
// 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)
|
.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 }));
|
.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?.({
|
await onSend?.({
|
||||||
to: toAddresses,
|
to: toAddresses,
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ export const ResizableImage = Node.create({
|
|||||||
alt: { default: null },
|
alt: { default: null },
|
||||||
title: { default: null },
|
title: { default: null },
|
||||||
width: { 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,
|
Heading2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export interface InlineImageUpload {
|
||||||
|
src: string;
|
||||||
|
cid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface RichTextEditorProps {
|
interface RichTextEditorProps {
|
||||||
content: string;
|
content: string;
|
||||||
onChange: (html: string) => void;
|
onChange: (html: string) => void;
|
||||||
onImageUpload?: (file: File) => Promise<string | null>;
|
onImageUpload?: (file: File) => Promise<InlineImageUpload | null>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
hasError?: boolean;
|
hasError?: boolean;
|
||||||
@@ -120,11 +125,11 @@ export function RichTextEditor({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
upload(file).then((url) => {
|
upload(file).then((result) => {
|
||||||
if (url) {
|
if (result) {
|
||||||
const { state } = view;
|
const { state } = view;
|
||||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
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);
|
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
||||||
view.dispatch(tr);
|
view.dispatch(tr);
|
||||||
}
|
}
|
||||||
@@ -141,10 +146,10 @@ export function RichTextEditor({
|
|||||||
if (imageFiles.length === 0) return false;
|
if (imageFiles.length === 0) return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
upload(file).then((url) => {
|
upload(file).then((result) => {
|
||||||
if (url) {
|
if (result) {
|
||||||
const { state } = view;
|
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);
|
const tr = state.tr.replaceSelectionWith(node);
|
||||||
view.dispatch(tr);
|
view.dispatch(tr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
_identityId?: string,
|
_identityId?: string,
|
||||||
_fromEmail?: string,
|
_fromEmail?: string,
|
||||||
draftId?: 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,
|
_fromName?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
|
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
|
||||||
@@ -365,7 +365,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
draftId?: string,
|
draftId?: string,
|
||||||
_fromName?: string,
|
_fromName?: string,
|
||||||
htmlBody?: 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> {
|
): Promise<void> {
|
||||||
// Remove draft if updating
|
// Remove draft if updating
|
||||||
if (draftId) {
|
if (draftId) {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export interface IJMAPClient {
|
|||||||
identityId?: string,
|
identityId?: string,
|
||||||
fromEmail?: string,
|
fromEmail?: string,
|
||||||
draftId?: 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,
|
fromName?: string,
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ export interface IJMAPClient {
|
|||||||
draftId?: string,
|
draftId?: string,
|
||||||
fromName?: string,
|
fromName?: string,
|
||||||
htmlBody?: 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>;
|
): Promise<void>;
|
||||||
|
|
||||||
sendImipReply(opts: {
|
sendImipReply(opts: {
|
||||||
|
|||||||
+7
-5
@@ -1701,7 +1701,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
identityId?: string,
|
identityId?: string,
|
||||||
fromEmail?: string,
|
fromEmail?: string,
|
||||||
draftId?: 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
|
fromName?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const mailboxes = await this.getMailboxes();
|
const mailboxes = await this.getMailboxes();
|
||||||
@@ -1722,7 +1722,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
mailboxIds: Record<string, boolean>;
|
mailboxIds: Record<string, boolean>;
|
||||||
bodyValues: Record<string, { value: string }>;
|
bodyValues: Record<string, { value: string }>;
|
||||||
textBody: { partId: 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 = {
|
const emailData: EmailDraft = {
|
||||||
@@ -1742,7 +1742,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
blobId: att.blobId,
|
blobId: att.blobId,
|
||||||
type: att.type,
|
type: att.type,
|
||||||
name: att.name,
|
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,
|
draftId?: string,
|
||||||
fromName?: string,
|
fromName?: string,
|
||||||
htmlBody?: 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> {
|
): Promise<void> {
|
||||||
const emailId = `send-${Date.now()}`;
|
const emailId = `send-${Date.now()}`;
|
||||||
const mailboxes = await this.getMailboxes();
|
const mailboxes = await this.getMailboxes();
|
||||||
@@ -1865,7 +1866,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
blobId: att.blobId,
|
blobId: att.blobId,
|
||||||
type: att.type,
|
type: att.type,
|
||||||
name: att.name,
|
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>;
|
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
||||||
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
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>;
|
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
|
||||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||||
|
|||||||
Reference in New Issue
Block a user