fix: include original attachments when forwarding an email #214

This commit is contained in:
Linus Rath
2026-04-21 13:58:02 +02:00
parent 24c53e5ce7
commit 89d8282846
2 changed files with 49 additions and 15 deletions
+2 -1
View File
@@ -1819,7 +1819,8 @@ export default function Home() {
subject: selectedEmail.subject, subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
receivedAt: selectedEmail.receivedAt receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments,
} : undefined)} } : undefined)}
initialDraftText={composerDraftText} initialDraftText={composerDraftText}
initialData={pendingDraft} initialData={pendingDraft}
+47 -14
View File
@@ -86,9 +86,21 @@ interface EmailComposerProps {
htmlBody?: string; htmlBody?: string;
receivedAt?: string; receivedAt?: string;
accountId?: string; accountId?: string;
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
}; };
} }
type ComposerAttachment = {
file?: File;
name: string;
type: string;
size: number;
blobId?: string;
uploading?: boolean;
error?: boolean;
abortController?: AbortController;
};
export function EmailComposer({ export function EmailComposer({
onSend, onSend,
onClose, onClose,
@@ -201,7 +213,21 @@ export function EmailComposer({
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
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<ComposerAttachment[]>(() => {
if (mode === 'forward' && replyTo?.attachments?.length) {
return replyTo.attachments
// Skip inline cid-referenced images — they're embedded in the forwarded HTML body
// (matches the viewer's hideInlineImageAttachments logic).
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
.map(att => ({
name: att.name || 'attachment',
type: att.type || 'application/octet-stream',
size: att.size,
blobId: att.blobId,
}));
}
return [];
});
const inlineImagesRef = useRef<Array<{ cid: string; blobId: string; type: string; name: string; size: number; dataUrl: string }>>([]); 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 }>({});
@@ -325,7 +351,7 @@ export function EmailComposer({
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId }; stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
// Track initial values for dirty detection (captured once on first render) // Track initial values for dirty detection (captured once on first render)
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: 0 }); const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
const isDirtyRef = useRef(false); const isDirtyRef = useRef(false);
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc || isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject || bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
@@ -524,9 +550,16 @@ export function EmailComposer({
const addFiles = useCallback(async (files: File[]) => { const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return; if (!client || files.length === 0) return;
const newAttachments = files.map(file => { const newAttachments: ComposerAttachment[] = files.map(file => {
const controller = new AbortController(); const controller = new AbortController();
return { file, uploading: true, abortController: controller }; return {
file,
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
uploading: true,
abortController: controller,
};
}); });
setAttachments(prev => [...prev, ...newAttachments]); setAttachments(prev => [...prev, ...newAttachments]);
@@ -669,9 +702,9 @@ export function EmailComposer({
.filter(att => att.blobId && !att.uploading) .filter(att => att.blobId && !att.uploading)
.map(att => ({ .map(att => ({
blobId: att.blobId!, blobId: att.blobId!,
name: att.file.name, name: att.name,
type: att.file.type, type: att.type,
size: att.file.size, size: att.size,
})); }));
// Create a hash of current data to compare with last saved // Create a hash of current data to compare with last saved
@@ -910,16 +943,16 @@ export function EmailComposer({
for (const att of attachments) { for (const att of attachments) {
if (att.error || att.uploading) continue; if (att.error || att.uploading) continue;
let content: ArrayBuffer; let content: ArrayBuffer;
if (att.file.size > 0) { if (att.file && att.file.size > 0) {
content = await att.file.arrayBuffer(); content = await att.file.arrayBuffer();
} else if (att.blobId && client) { } else if (att.blobId && client) {
content = await client.fetchBlobArrayBuffer(att.blobId, att.file.name, att.file.type); content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
} else { } else {
continue; continue;
} }
mimeAttachments.push({ mimeAttachments.push({
filename: att.file.name, filename: att.name,
contentType: att.file.type || 'application/octet-stream', contentType: att.type || 'application/octet-stream',
content, content,
}); });
} }
@@ -994,7 +1027,7 @@ export function EmailComposer({
// Collect uploaded attachment blobIds for the send request // Collect uploaded attachment blobIds for the send request
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = 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.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments); uploadedAttachments.push(...inlineAttachments);
await onSend?.({ await onSend?.({
@@ -1375,9 +1408,9 @@ export function EmailComposer({
) : ( ) : (
<Paperclip className="w-3 h-3 flex-shrink-0" /> <Paperclip className="w-3 h-3 flex-shrink-0" />
)} )}
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.file.name}</span> <span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap"> <span className="text-xs text-muted-foreground whitespace-nowrap">
({formatFileSize(att.file.size)}) ({formatFileSize(att.size)})
</span> </span>
<button <button
onClick={() => removeAttachment(index)} onClick={() => removeAttachment(index)}