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.
This commit is contained in:
Aaron Guise
2026-07-27 15:31:52 +12:00
parent 9c04950a94
commit 3ea22161d9
28 changed files with 186 additions and 0 deletions
+48
View File
@@ -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.
+22
View File
@@ -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({
</button>
)}
<div className="h-px bg-border my-1" />
{/* Forward as attachment */}
{onForwardAsAttachment && (
<button
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Paperclip className="w-4 h-4" />
{t('forward_as_attachment')}
</button>
)}
{/* Export email */}
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
@@ -3462,6 +3475,15 @@ export function EmailViewer({
</button>
)}
<div className="h-px bg-border my-1" />
{onForwardAsAttachment && (
<button
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Paperclip className="w-5 h-5" />
{t('forward_as_attachment')}
</button>
)}
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { buildForwardAsAttachmentPayload } from '@/lib/forward-as-attachment';
import type { Email } from '@/lib/jmap/types';
function makeEmail(overrides: Partial<Email> = {}): Email {
return {
id: 'e1',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: {},
size: 12345,
receivedAt: '2026-07-26T22:25:22Z',
subject: 'Your waste service day is changing',
hasAttachment: false,
blobId: 'blob123',
...overrides,
};
}
describe('buildForwardAsAttachmentPayload', () => {
it('returns null when the email has no blobId', () => {
const email = makeEmail({ blobId: undefined });
expect(buildForwardAsAttachmentPayload(email, 'Fwd:')).toBeNull();
});
it('prefixes the subject using the given forward prefix', () => {
const email = makeEmail({ subject: 'Missed spam example' });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.subject).toBe('Fwd: Missed spam example');
});
it('builds a message/rfc822 attachment referencing the email\'s own blobId, not a new upload', () => {
const email = makeEmail({ blobId: 'the-real-blob-id', size: 26489 });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.attachment).toEqual({
blobId: 'the-real-blob-id',
name: expect.stringMatching(/\.eml$/),
type: 'message/rfc822',
size: 26489,
});
});
it('is idempotent - repeated forwarding does not stack prefixes', () => {
const email = makeEmail({ subject: 'Fwd: already forwarded once' });
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
expect(payload?.subject).toBe('Fwd: already forwarded once');
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { Email } from "@/lib/jmap/types";
import { buildForwardSubject } from "@/lib/subject-prefix";
import { emailExportFilename } from "@/lib/download-filename";
export interface ForwardAsAttachmentEntry {
blobId: string;
name: string;
type: "message/rfc822";
size: number;
}
export interface ForwardAsAttachmentPayload {
subject: string;
attachment: ForwardAsAttachmentEntry;
}
/**
* Build the subject and synthetic attachment entry for forwarding a
* message as a message/rfc822 attachment instead of inline-quoted text
* (e.g. reporting spam to an upstream gateway that expects the raw
* original as an attachment, or preserving exact formatting/headers).
*
* Referenced by blobId, not re-uploaded - JMAP blobs are account-scoped,
* not per-email, so the same blobId a message already has can be attached
* to a brand new outgoing email directly.
*
* Returns null when the email has no blobId (nothing to reference).
*/
export function buildForwardAsAttachmentPayload(
email: Email,
forwardPrefix: string,
): ForwardAsAttachmentPayload | null {
if (!email.blobId) return null;
return {
subject: buildForwardSubject(email.subject, forwardPrefix),
attachment: {
blobId: email.blobId,
name: emailExportFilename(email),
type: "message/rfc822",
size: email.size,
},
};
}
+1
View File
@@ -298,6 +298,7 @@
"print": "طباعة",
"view_source": "عرض المصدر",
"export_email": "تصدير كملف .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "استيراد ملف .eml أو .zip",
"keyboard_shortcuts": "اختصارات لوحة المفاتيح (؟)",
"email_source": "مصدر الرسالة",
+1
View File
@@ -298,6 +298,7 @@
"print": "Imprimeix",
"view_source": "Mostra el codi font",
"export_email": "Exporta com a .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importa .eml o .zip",
"keyboard_shortcuts": "Dreceres de teclat (?)",
"email_source": "Codi font del correu",
+1
View File
@@ -298,6 +298,7 @@
"print": "Tisk",
"view_source": "Zobrazit zdrojový kód",
"export_email": "Exportovat jako .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importovat .eml nebo .zip",
"keyboard_shortcuts": "Klávesové zkratky (?)",
"email_source": "Zdrojový kód zprávy",
+1
View File
@@ -298,6 +298,7 @@
"print": "Udskriv",
"view_source": "Vis kilde",
"export_email": "Eksportér som .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importér .eml eller .zip",
"keyboard_shortcuts": "Tastaturgenveje (?)",
"email_source": "E-mail-kilde",
+1
View File
@@ -298,6 +298,7 @@
"print": "Drucken",
"view_source": "Quelltext anzeigen",
"export_email": "Als .eml exportieren",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml oder .zip importieren",
"keyboard_shortcuts": "Tastaturkürzel (?)",
"email_source": "E-Mail-Quelltext",
+1
View File
@@ -298,6 +298,7 @@
"print": "Print",
"view_source": "View source",
"export_email": "Export as .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Import .eml or .zip",
"keyboard_shortcuts": "Keyboard shortcuts (?)",
"email_source": "Email Source",
+1
View File
@@ -298,6 +298,7 @@
"print": "Imprimir",
"view_source": "Ver código fuente",
"export_email": "Exportar como .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importar .eml o .zip",
"keyboard_shortcuts": "Atajos de teclado (?)",
"email_source": "Código Fuente del Correo",
+1
View File
@@ -298,6 +298,7 @@
"print": "چاپ",
"view_source": "مشاهده منبع",
"export_email": "خروجی .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "وارد کردن .eml یا .zip",
"keyboard_shortcuts": "میانبرهای صفحه کلید (?)",
"email_source": "منبع ایمیل",
+1
View File
@@ -298,6 +298,7 @@
"print": "Imprimer",
"view_source": "Voir la source",
"export_email": "Exporter en .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importer .eml ou .zip",
"keyboard_shortcuts": "Raccourcis clavier (?)",
"email_source": "Source de l'email",
+1
View File
@@ -245,6 +245,7 @@
"print": "הדפס",
"view_source": "צפה במקור",
"export_email": "ייצא כ-.eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "ייבוא .eml",
"keyboard_shortcuts": "קיצורי מקשים (?)",
"email_source": "מקור דוא\"ל",
+1
View File
@@ -298,6 +298,7 @@
"print": "Nyomtatás",
"view_source": "Forrás megtekintése",
"export_email": "Exportálás .eml-ként",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importálás .eml vagy .zip fájlból",
"keyboard_shortcuts": "Billentyűparancsok (?)",
"email_source": "E-mail forrás",
+1
View File
@@ -298,6 +298,7 @@
"print": "Stampa",
"view_source": "Visualizza sorgente",
"export_email": "Esporta come .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importa .eml o .zip",
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
"email_source": "Sorgente del messaggio",
+1
View File
@@ -298,6 +298,7 @@
"print": "印刷",
"view_source": "ソースを表示",
"export_email": ".emlとしてエクスポート",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml または .zip をインポート",
"keyboard_shortcuts": "キーボードショートカット (?)",
"email_source": "メールソース",
+1
View File
@@ -298,6 +298,7 @@
"print": "인쇄",
"view_source": "원본 보기",
"export_email": ".eml 파일로 내보내기",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml 또는 .zip 가져오기",
"keyboard_shortcuts": "단축키 (?)",
"email_source": "메일 원본",
+1
View File
@@ -298,6 +298,7 @@
"print": "Drukāt",
"view_source": "Skatīt avota kodu",
"export_email": "Eksportēt kā .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importēt .eml vai .zip",
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
"email_source": "Vēstules avota kods",
+1
View File
@@ -298,6 +298,7 @@
"print": "Afdrukken",
"view_source": "Bron bekijken",
"export_email": "Exporteren als .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml of .zip importeren",
"keyboard_shortcuts": "Sneltoetsen (?)",
"email_source": "E-mailbron",
+1
View File
@@ -298,6 +298,7 @@
"print": "Drukuj",
"view_source": "Pokaż źródło",
"export_email": "Eksportuj jako .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importuj .eml lub .zip",
"keyboard_shortcuts": "Skróty klawiszowe (?)",
"email_source": "Źródło wiadomości",
+1
View File
@@ -298,6 +298,7 @@
"print": "Imprimir",
"view_source": "Ver código-fonte",
"export_email": "Exportar como .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importar .eml ou .zip",
"keyboard_shortcuts": "Atalhos de teclado (?)",
"email_source": "Código-fonte do E-mail",
+1
View File
@@ -298,6 +298,7 @@
"print": "Imprimare",
"view_source": "Vizualizați sursa",
"export_email": "Exportați ca fișier .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importați fișiere .eml sau .zip",
"keyboard_shortcuts": "Comenzi rapide de la tastatură (?)",
"email_source": "Sursa e-mailului",
+1
View File
@@ -298,6 +298,7 @@
"print": "Распечатать",
"view_source": "Просмотреть исходный код",
"export_email": "Экспортировать как .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Импортировать .eml или .zip",
"keyboard_shortcuts": "Сочетания клавиш (?)",
"email_source": "Исходный код письма",
+1
View File
@@ -298,6 +298,7 @@
"print": "Tlačiť",
"view_source": "Zobraziť zdrojový kód",
"export_email": "Exportovať ako .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Importovať .eml alebo .zip",
"keyboard_shortcuts": "Klávesové skratky (?)",
"email_source": "Zdrojový kód e-mailu",
+1
View File
@@ -298,6 +298,7 @@
"print": "Yazdır",
"view_source": "Kaynağı görüntüle",
"export_email": ".eml olarak dışa aktar",
"forward_as_attachment": "Forward as attachment",
"import_email": ".eml veya .zip içe aktar",
"keyboard_shortcuts": "Klavye kısayolları (?)",
"email_source": "E-posta Kaynağı",
+1
View File
@@ -298,6 +298,7 @@
"print": "Роздрукувати",
"view_source": "Переглянути джерело",
"export_email": "Експортувати як .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "Імпорт .eml або .zip",
"keyboard_shortcuts": "Комбінації клавіш (?)",
"email_source": "Джерело електронної пошти",
+1
View File
@@ -298,6 +298,7 @@
"print": "打印",
"view_source": "查看源码",
"export_email": "导出为 .eml",
"forward_as_attachment": "Forward as attachment",
"import_email": "导入 .eml 或 .zip",
"keyboard_shortcuts": "键盘快捷键(?)",
"email_source": "邮件源码",