From 89d8282846889fdd3d4c7ec16cd6a4a2caab506a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:58:02 +0200 Subject: [PATCH] fix: include original attachments when forwarding an email #214 --- app/[locale]/page.tsx | 3 +- components/email/email-composer.tsx | 61 ++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 190874a4..3fc1c43f 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1819,7 +1819,8 @@ export default function Home() { subject: selectedEmail.subject, body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '', htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined, - receivedAt: selectedEmail.receivedAt + receivedAt: selectedEmail.receivedAt, + attachments: selectedEmail.attachments, } : undefined)} initialDraftText={composerDraftText} initialData={pendingDraft} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 710f3ae1..6422d774 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -86,9 +86,21 @@ interface EmailComposerProps { htmlBody?: string; receivedAt?: 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({ onSend, onClose, @@ -201,7 +213,21 @@ export function EmailComposer({ const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const saveTimeoutRef = useRef(null); const lastSavedDataRef = useRef(""); - const [attachments, setAttachments] = useState>([]); + const [attachments, setAttachments] = useState(() => { + 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>([]); const fileInputRef = useRef(null); 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 }; // 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); isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc || bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject || @@ -524,9 +550,16 @@ export function EmailComposer({ const addFiles = useCallback(async (files: File[]) => { if (!client || files.length === 0) return; - const newAttachments = files.map(file => { + const newAttachments: ComposerAttachment[] = files.map(file => { 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]); @@ -669,9 +702,9 @@ export function EmailComposer({ .filter(att => att.blobId && !att.uploading) .map(att => ({ blobId: att.blobId!, - name: att.file.name, - type: att.file.type, - size: att.file.size, + name: att.name, + type: att.type, + size: att.size, })); // Create a hash of current data to compare with last saved @@ -910,16 +943,16 @@ export function EmailComposer({ for (const att of attachments) { if (att.error || att.uploading) continue; let content: ArrayBuffer; - if (att.file.size > 0) { + if (att.file && att.file.size > 0) { content = await att.file.arrayBuffer(); } 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 { continue; } mimeAttachments.push({ - filename: att.file.name, - contentType: att.file.type || 'application/octet-stream', + filename: att.name, + contentType: att.type || 'application/octet-stream', content, }); } @@ -994,7 +1027,7 @@ export function EmailComposer({ // 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 .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); await onSend?.({ @@ -1375,9 +1408,9 @@ export function EmailComposer({ ) : ( )} - {att.file.name} + {att.name} - ({formatFileSize(att.file.size)}) + ({formatFileSize(att.size)})