diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 7345bb80..98d71d31 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -77,6 +77,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from import { emailToReadView } from "@/lib/plugin-projection"; import { buildQuoteHeader } from "@/lib/quote-header"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; +import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; import { getEffectiveLocale } from '@/i18n/detect-locale'; import type { QuoteHeader } from "@/lib/plugin-types"; @@ -784,7 +785,15 @@ export default function Home() { // This makes the Pro composer behave like Thunderbird's pop-out window. useEffect(() => { if (!isEmbedded || !showComposer) return; - const replyTo = selectedEmail ? { + // pendingDraft.replyTo, when set, was built by the opener (e.g. + // handleForwardAsAttachment) with intent that must survive the hop into + // the Pro tab - mirrors the same precedence the non-embedded render path + // uses just below (`replyTo={pendingDraft !== null ? pendingDraft.replyTo + // : ...}`). Building fresh from selectedEmail unconditionally here would + // silently drop that intent (e.g. the synthetic message/rfc822 + // attachment "Forward as attachment" stages), falling back to a normal + // quoted forward instead. + const replyTo = pendingDraft?.replyTo ?? (selectedEmail ? { from: selectedEmail.from, replyToAddresses: selectedEmail.replyTo, to: selectedEmail.to, @@ -800,7 +809,7 @@ export default function Home() { quoteHeaderHtml: composerQuoteHeader?.html, quoteHeaderText: composerQuoteHeader?.text, quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote, - } : undefined; + } : undefined); const effectiveMode = pendingDraft?.mode ?? composerMode; const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? ''; @@ -1539,6 +1548,77 @@ export default function Home() { if (isMobile) setActiveView('viewer'); }; + // Forward the original message as a message/rfc822 attachment instead of + // inline-quoted text - e.g. for reporting spam to an upstream gateway + // that expects the raw original as an attachment, or preserving exact + // formatting/headers the recipient needs to see untouched. Reuses the + // same attachment-carry-forward mechanism native Forward already uses + // for a forwarded message's own attachments (see the `attachments` + // useState initializer in email-composer.tsx) - we just add one more + // synthetic entry representing the whole original message, referenced + // by its existing blobId (no re-fetch/re-upload needed - JMAP blobs are + // account-scoped, not per-email). Skips prepareComposerQuoteHeader + // entirely, so the body starts blank instead of quoting the original. + // Takes an explicit `email` (defaulting to selectedEmail), same pattern + // handleDelete uses just below, rather than always reading selectedEmail + // from this closure - callers that just called selectEmail(email) and + // invoke this synchronously in the same tick would otherwise see the + // PRE-update value (the Zustand store updates immediately, but this + // render's selectedEmail closure doesn't until the next render), + // forwarding the previously selected message or no-op'ing on an + // unselected row. See the list context-menu wiring below. + const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => { + if (!email) return; + // Same filename options "Export as .eml" uses (see emailFilenameOptions + // in email-viewer.tsx), so the two actions produce consistent filenames + // for the same message rather than the synthetic attachment silently + // ignoring the user's configured naming template. + const { + emailDownloadTemplate, + filenameSpaceReplacement, + filenameLowercase, + filenameStripDiacritics, + filenameCollapseSeparators, + } = useSettingsStore.getState(); + const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), { + template: emailDownloadTemplate, + spaceReplacement: filenameSpaceReplacement, + lowercase: filenameLowercase, + stripDiacritics: filenameStripDiacritics, + collapseSeparators: filenameCollapseSeparators, + }); + if (!payload) return; + + const ok = await emailHooks.onBeforeForward.intercept({ + originalEmailId: email.id, + originalEmail: emailToReadView(email), + mode: 'forward' as const, + }); + if (!ok) return; + + startFreshComposerSession(); + setPendingDraft({ + to: "", + cc: "", + bcc: "", + subject: payload.subject, + body: "", + showCc: false, + showBcc: false, + selectedIdentityId: null, + subAddressTag: "", + mode: "forward", + draftId: null, + replyTo: { + subject: email.subject, + attachments: [payload.attachment], + }, + }); + setComposerMode('forward'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }; + const handleDelete = async (emailToDelete: Email | null = selectedEmail) => { if (!client || !emailToDelete) return; @@ -3206,6 +3286,10 @@ export default function Home() { selectEmail(email); handleForward(); }} + onForwardAsAttachment={(email) => { + selectEmail(email); + handleForwardAsAttachment(email); + }} onMarkAsRead={async (email, read) => { if (client) { await markAsRead(client, email.id, read); @@ -3436,6 +3520,7 @@ export default function Home() { onReply={handleReply} onReplyAll={handleReplyAll} onForward={handleForward} + onForwardAsAttachment={handleForwardAsAttachment} onDelete={() => { // Deleting the open message returns to the list (Gmail-style), // not the next email — unless the user turned the setting off. diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 7cdfb1f7..d74ed5cb 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -34,6 +34,7 @@ import { EditIcon, CalendarClock, XCircle, + Paperclip, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; @@ -59,6 +60,7 @@ interface EmailContextMenuProps { onReply?: () => void; onReplyAll?: () => void; onForward?: () => void; + onForwardAsAttachment?: () => void; onMarkAsRead?: (read: boolean) => void; onToggleStar?: () => void; onTogglePinned?: () => void; @@ -127,6 +129,7 @@ export function EmailContextMenu({ onReply, onReplyAll, onForward, + onForwardAsAttachment, onMarkAsRead, onToggleStar, onTogglePinned, @@ -150,6 +153,7 @@ export function EmailContextMenu({ const t = useTranslations("context_menu"); const tSidebar = useTranslations("sidebar"); const _tColor = useTranslations("email_viewer.color_tag"); + const tEmailViewer = useTranslations("email_viewer"); const emailKeywords = useSettingsStore((state) => state.emailKeywords); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; @@ -277,6 +281,12 @@ export function EmailContextMenu({ onClick={() => handleAction(onForward!)} disabled={!onForward} /> + handleAction(onForwardAsAttachment!)} + disabled={!onForwardAsAttachment || !email.blobId} + /> )} diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 8f1db908..6dfe4c33 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -33,6 +33,7 @@ interface EmailListProps { onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; onForward?: (email: Email) => void; + onForwardAsAttachment?: (email: Email) => void; onMarkAsRead?: (email: Email, read: boolean) => void; onToggleStar?: (email: Email) => void; onTogglePinned?: (email: Email) => void; @@ -63,6 +64,7 @@ export function EmailList({ onReply, onReplyAll, onForward, + onForwardAsAttachment, onMarkAsRead, onToggleStar, onTogglePinned, @@ -583,6 +585,7 @@ export function EmailList({ onReply={() => onReply?.(contextMenu.data!)} onReplyAll={() => onReplyAll?.(contextMenu.data!)} onForward={() => onForward?.(contextMenu.data!)} + onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenu.data!)} onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)} onToggleStar={() => onToggleStar?.(contextMenu.data!)} onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index dd59209e..0aa7fb97 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -19,6 +19,7 @@ import { Reply, ReplyAll, Forward, + Paperclip, Trash2, Archive, Star, @@ -109,6 +110,7 @@ interface EmailViewerProps { onReply?: (draftText?: string) => void; onReplyAll?: () => void; onForward?: () => void; + onForwardAsAttachment?: () => void; onDelete?: () => void; onArchive?: () => void; onToggleStar?: () => void; @@ -621,6 +623,7 @@ export function EmailViewer({ onReply, onReplyAll, onForward, + onForwardAsAttachment, onDelete, onArchive, onToggleStar, @@ -3340,6 +3343,16 @@ export function EmailViewer({ )}
+ {/* Forward as attachment */} + {onForwardAsAttachment && email?.blobId && ( + + )} {/* Export email */} )}
+ {onForwardAsAttachment && email?.blobId && ( + + )}