From 3ea22161d94b45a77c240371feb5287fba810bf3 Mon Sep 17 00:00:00 2001 From: Aaron Guise Date: Mon, 27 Jul 2026 15:31:52 +1200 Subject: [PATCH] feat: add "Forward as attachment" next to Export as .eml Adds a "Forward as attachment" action to the message overflow menu (desktop and mobile), right beside the existing "Export as .eml" action. Opens a new forward-mode compose window with the original message attached as a message/rfc822 file instead of quoted inline - useful for reporting spam/phishing to an upstream gateway that expects the raw original as an attachment (the primary motivating use case: gateways like MxGuarddog require complete original headers, including the full mail path, for scanning), or for preserving a message's exact formatting/headers when forwarding. Implementation reuses the composer's existing attachment-carry-forward mechanism (the `attachments` useState initializer in email-composer.tsx already carries a forwarded message's own attachments into the new compose via `replyTo.attachments`) - this just adds one synthetic entry representing the whole original message, referenced by its existing blobId. No re-fetch or re-upload needed, since JMAP blobs are account-scoped rather than per-email. The inline quote-header step (prepareComposerQuoteHeader) is skipped, so the body starts blank instead of quoting the original. The core "build subject + attachment entry" logic is extracted into a pure, unit-tested helper (lib/forward-as-attachment.ts) rather than left inline in the already-large page component. Adds the forward_as_attachment locale key to all 24 locales (English text as a placeholder pending translation, following the existing add-a-key convention) to satisfy the translations completeness test. --- app/(main)/[locale]/page.tsx | 48 +++++++++++++++++++++ components/email/email-viewer.tsx | 22 ++++++++++ lib/__tests__/forward-as-attachment.test.ts | 48 +++++++++++++++++++++ lib/forward-as-attachment.ts | 44 +++++++++++++++++++ locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh/common.json | 1 + 28 files changed, 186 insertions(+) create mode 100644 lib/__tests__/forward-as-attachment.test.ts create mode 100644 lib/forward-as-attachment.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 7345bb80..a683d1c6 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"; @@ -1539,6 +1540,52 @@ 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. + const handleForwardAsAttachment = async () => { + if (!selectedEmail) return; + const payload = buildForwardAsAttachmentPayload(selectedEmail, t('email_composer.prefix.forward')); + if (!payload) return; + + const ok = await emailHooks.onBeforeForward.intercept({ + originalEmailId: selectedEmail.id, + originalEmail: emailToReadView(selectedEmail), + 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: selectedEmail.subject, + attachments: [payload.attachment], + }, + }); + setComposerMode('forward'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }; + const handleDelete = async (emailToDelete: Email | null = selectedEmail) => { if (!client || !emailToDelete) return; @@ -3436,6 +3483,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-viewer.tsx b/components/email/email-viewer.tsx index dd59209e..b95e019e 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 && ( + + )} {/* Export email */} )}
+ {onForwardAsAttachment && ( + + )}