Merge pull request #698 from guisea/feature/forward-as-attachment
feat: add "Forward as attachment" next to Export as .eml
This commit is contained in:
@@ -77,6 +77,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from
|
|||||||
import { emailToReadView } from "@/lib/plugin-projection";
|
import { emailToReadView } from "@/lib/plugin-projection";
|
||||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
|
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||||
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
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.
|
// This makes the Pro composer behave like Thunderbird's pop-out window.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEmbedded || !showComposer) return;
|
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,
|
from: selectedEmail.from,
|
||||||
replyToAddresses: selectedEmail.replyTo,
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
to: selectedEmail.to,
|
to: selectedEmail.to,
|
||||||
@@ -800,7 +809,7 @@ export default function Home() {
|
|||||||
quoteHeaderHtml: composerQuoteHeader?.html,
|
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||||
quoteHeaderText: composerQuoteHeader?.text,
|
quoteHeaderText: composerQuoteHeader?.text,
|
||||||
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||||
} : undefined;
|
} : undefined);
|
||||||
|
|
||||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||||
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
|
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
|
||||||
@@ -1539,6 +1548,77 @@ export default function Home() {
|
|||||||
if (isMobile) setActiveView('viewer');
|
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) => {
|
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||||
if (!client || !emailToDelete) return;
|
if (!client || !emailToDelete) return;
|
||||||
|
|
||||||
@@ -3206,6 +3286,10 @@ export default function Home() {
|
|||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
handleForward();
|
handleForward();
|
||||||
}}
|
}}
|
||||||
|
onForwardAsAttachment={(email) => {
|
||||||
|
selectEmail(email);
|
||||||
|
handleForwardAsAttachment(email);
|
||||||
|
}}
|
||||||
onMarkAsRead={async (email, read) => {
|
onMarkAsRead={async (email, read) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
await markAsRead(client, email.id, read);
|
await markAsRead(client, email.id, read);
|
||||||
@@ -3436,6 +3520,7 @@ export default function Home() {
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
|
onForwardAsAttachment={handleForwardAsAttachment}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
// Deleting the open message returns to the list (Gmail-style),
|
// Deleting the open message returns to the list (Gmail-style),
|
||||||
// not the next email — unless the user turned the setting off.
|
// not the next email — unless the user turned the setting off.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
EditIcon,
|
EditIcon,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
Paperclip,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
@@ -59,6 +60,7 @@ interface EmailContextMenuProps {
|
|||||||
onReply?: () => void;
|
onReply?: () => void;
|
||||||
onReplyAll?: () => void;
|
onReplyAll?: () => void;
|
||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
|
onForwardAsAttachment?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onTogglePinned?: () => void;
|
onTogglePinned?: () => void;
|
||||||
@@ -127,6 +129,7 @@ export function EmailContextMenu({
|
|||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
onForward,
|
onForward,
|
||||||
|
onForwardAsAttachment,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onTogglePinned,
|
onTogglePinned,
|
||||||
@@ -150,6 +153,7 @@ export function EmailContextMenu({
|
|||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
const tSidebar = useTranslations("sidebar");
|
const tSidebar = useTranslations("sidebar");
|
||||||
const _tColor = useTranslations("email_viewer.color_tag");
|
const _tColor = useTranslations("email_viewer.color_tag");
|
||||||
|
const tEmailViewer = useTranslations("email_viewer");
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
@@ -277,6 +281,12 @@ export function EmailContextMenu({
|
|||||||
onClick={() => handleAction(onForward!)}
|
onClick={() => handleAction(onForward!)}
|
||||||
disabled={!onForward}
|
disabled={!onForward}
|
||||||
/>
|
/>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={Paperclip}
|
||||||
|
label={tEmailViewer("forward_as_attachment")}
|
||||||
|
onClick={() => handleAction(onForwardAsAttachment!)}
|
||||||
|
disabled={!onForwardAsAttachment || !email.blobId}
|
||||||
|
/>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface EmailListProps {
|
|||||||
onReply?: (email: Email) => void;
|
onReply?: (email: Email) => void;
|
||||||
onReplyAll?: (email: Email) => void;
|
onReplyAll?: (email: Email) => void;
|
||||||
onForward?: (email: Email) => void;
|
onForward?: (email: Email) => void;
|
||||||
|
onForwardAsAttachment?: (email: Email) => void;
|
||||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||||
onToggleStar?: (email: Email) => void;
|
onToggleStar?: (email: Email) => void;
|
||||||
onTogglePinned?: (email: Email) => void;
|
onTogglePinned?: (email: Email) => void;
|
||||||
@@ -63,6 +64,7 @@ export function EmailList({
|
|||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
onForward,
|
onForward,
|
||||||
|
onForwardAsAttachment,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
onTogglePinned,
|
onTogglePinned,
|
||||||
@@ -583,6 +585,7 @@ export function EmailList({
|
|||||||
onReply={() => onReply?.(contextMenu.data!)}
|
onReply={() => onReply?.(contextMenu.data!)}
|
||||||
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
||||||
onForward={() => onForward?.(contextMenu.data!)}
|
onForward={() => onForward?.(contextMenu.data!)}
|
||||||
|
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenu.data!)}
|
||||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
||||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
||||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Reply,
|
Reply,
|
||||||
ReplyAll,
|
ReplyAll,
|
||||||
Forward,
|
Forward,
|
||||||
|
Paperclip,
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
Star,
|
Star,
|
||||||
@@ -109,6 +110,7 @@ interface EmailViewerProps {
|
|||||||
onReply?: (draftText?: string) => void;
|
onReply?: (draftText?: string) => void;
|
||||||
onReplyAll?: () => void;
|
onReplyAll?: () => void;
|
||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
|
onForwardAsAttachment?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
@@ -621,6 +623,7 @@ export function EmailViewer({
|
|||||||
onReply,
|
onReply,
|
||||||
onReplyAll,
|
onReplyAll,
|
||||||
onForward,
|
onForward,
|
||||||
|
onForwardAsAttachment,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
@@ -3340,6 +3343,16 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
|
{/* Forward as attachment */}
|
||||||
|
{onForwardAsAttachment && email?.blobId && (
|
||||||
|
<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 */}
|
{/* Export email */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
@@ -3462,6 +3475,15 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
|
{onForwardAsAttachment && email?.blobId && (
|
||||||
|
<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
|
<button
|
||||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
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"
|
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/st
|
|||||||
import type { Email } from "@/lib/jmap/types";
|
import type { Email } from "@/lib/jmap/types";
|
||||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||||
|
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||||
|
|
||||||
interface ProEmailTabBodyProps {
|
interface ProEmailTabBodyProps {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
@@ -136,6 +137,50 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
});
|
});
|
||||||
}, [email, openComposeTab, t]);
|
}, [email, openComposeTab, t]);
|
||||||
|
|
||||||
|
// Mirrors handleForward, but attaches the original as a message/rfc822
|
||||||
|
// file instead of quoting it inline - see lib/forward-as-attachment.ts.
|
||||||
|
// This is a separate, self-contained render path from the main Mail
|
||||||
|
// tab's EmailViewer (page.tsx) - Pro tabs fetch their own `email` and
|
||||||
|
// open compose tabs directly via useProTabStore, not through
|
||||||
|
// page.tsx's pendingDraft/selectedEmail plumbing - so it needed its own
|
||||||
|
// wiring rather than falling out of the page.tsx fix automatically.
|
||||||
|
const handleForwardAsAttachment = useCallback(() => {
|
||||||
|
if (!email) return;
|
||||||
|
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;
|
||||||
|
|
||||||
|
composerSessionIdRef.current += 1;
|
||||||
|
openComposeTab({
|
||||||
|
sessionId: composerSessionIdRef.current,
|
||||||
|
mode: 'forward',
|
||||||
|
replyTo: {
|
||||||
|
subject: email.subject,
|
||||||
|
attachments: [payload.attachment],
|
||||||
|
},
|
||||||
|
sourceEmailId: email.id,
|
||||||
|
// payload.subject is intentionally blank for a subject-less email (to
|
||||||
|
// match normal Forward's *composer* subject behavior - see
|
||||||
|
// buildForwardAsAttachmentPayload). The Pro tab *title* is a separate
|
||||||
|
// UI label that still needs a sensible fallback, same as handleForward
|
||||||
|
// above uses - reusing payload.subject here would give the tab an
|
||||||
|
// empty title instead of e.g. "Fwd: New message".
|
||||||
|
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
||||||
|
});
|
||||||
|
}, [email, openComposeTab, t]);
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
if (!client || !email) return;
|
if (!client || !email) return;
|
||||||
try {
|
try {
|
||||||
@@ -283,6 +328,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onReplyAll={handleReplyAll}
|
onReplyAll={handleReplyAll}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
|
onForwardAsAttachment={handleForwardAsAttachment}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the subject blank (not just the bare prefix) for a subject-less message, matching normal Forward', () => {
|
||||||
|
const email = makeEmail({ subject: undefined });
|
||||||
|
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||||
|
expect(payload?.subject).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a custom filename template, matching "Export as .eml" naming instead of always using the default', () => {
|
||||||
|
const email = makeEmail({ subject: 'Missed spam example' });
|
||||||
|
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:', {
|
||||||
|
template: 'custom-{subject}',
|
||||||
|
lowercase: true,
|
||||||
|
spaceReplacement: 'dash',
|
||||||
|
});
|
||||||
|
expect(payload?.attachment.name).toBe('custom-missed-spam-example.eml');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Email } from "@/lib/jmap/types";
|
||||||
|
import { buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
|
import { emailExportFilename, type EmailFilenameOptions } 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.
|
||||||
|
*
|
||||||
|
* `filenameOptions`, when passed, should be the same options the caller
|
||||||
|
* uses for "Export as .eml" / drag-out (the user's configured filename
|
||||||
|
* template, space/case/diacritics transforms - see
|
||||||
|
* useSettingsStore's emailDownloadTemplate and friends), so the two
|
||||||
|
* actions produce consistent filenames for the same message. Falls back
|
||||||
|
* to emailExportFilename's own default template when omitted.
|
||||||
|
*
|
||||||
|
* Returns null when the email has no blobId (nothing to reference).
|
||||||
|
*/
|
||||||
|
export function buildForwardAsAttachmentPayload(
|
||||||
|
email: Email,
|
||||||
|
forwardPrefix: string,
|
||||||
|
filenameOptions?: EmailFilenameOptions,
|
||||||
|
): ForwardAsAttachmentPayload | null {
|
||||||
|
if (!email.blobId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Match the normal Forward flow's getInitialSubject(), which leaves the
|
||||||
|
// subject blank rather than prefix-only when the original has none -
|
||||||
|
// buildForwardSubject("", prefix) would otherwise return just the bare
|
||||||
|
// prefix (e.g. "Fwd:") for a subject-less message.
|
||||||
|
subject: email.subject ? buildForwardSubject(email.subject, forwardPrefix) : "",
|
||||||
|
attachment: {
|
||||||
|
blobId: email.blobId,
|
||||||
|
name: emailExportFilename(email, filenameOptions),
|
||||||
|
type: "message/rfc822",
|
||||||
|
size: email.size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "طباعة",
|
"print": "طباعة",
|
||||||
"view_source": "عرض المصدر",
|
"view_source": "عرض المصدر",
|
||||||
"export_email": "تصدير كملف .eml",
|
"export_email": "تصدير كملف .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "استيراد ملف .eml أو .zip",
|
"import_email": "استيراد ملف .eml أو .zip",
|
||||||
"keyboard_shortcuts": "اختصارات لوحة المفاتيح (؟)",
|
"keyboard_shortcuts": "اختصارات لوحة المفاتيح (؟)",
|
||||||
"email_source": "مصدر الرسالة",
|
"email_source": "مصدر الرسالة",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Imprimeix",
|
"print": "Imprimeix",
|
||||||
"view_source": "Mostra el codi font",
|
"view_source": "Mostra el codi font",
|
||||||
"export_email": "Exporta com a .eml",
|
"export_email": "Exporta com a .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importa .eml o .zip",
|
"import_email": "Importa .eml o .zip",
|
||||||
"keyboard_shortcuts": "Dreceres de teclat (?)",
|
"keyboard_shortcuts": "Dreceres de teclat (?)",
|
||||||
"email_source": "Codi font del correu",
|
"email_source": "Codi font del correu",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Tisk",
|
"print": "Tisk",
|
||||||
"view_source": "Zobrazit zdrojový kód",
|
"view_source": "Zobrazit zdrojový kód",
|
||||||
"export_email": "Exportovat jako .eml",
|
"export_email": "Exportovat jako .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importovat .eml nebo .zip",
|
"import_email": "Importovat .eml nebo .zip",
|
||||||
"keyboard_shortcuts": "Klávesové zkratky (?)",
|
"keyboard_shortcuts": "Klávesové zkratky (?)",
|
||||||
"email_source": "Zdrojový kód zprávy",
|
"email_source": "Zdrojový kód zprávy",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Udskriv",
|
"print": "Udskriv",
|
||||||
"view_source": "Vis kilde",
|
"view_source": "Vis kilde",
|
||||||
"export_email": "Eksportér som .eml",
|
"export_email": "Eksportér som .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importér .eml eller .zip",
|
"import_email": "Importér .eml eller .zip",
|
||||||
"keyboard_shortcuts": "Tastaturgenveje (?)",
|
"keyboard_shortcuts": "Tastaturgenveje (?)",
|
||||||
"email_source": "E-mail-kilde",
|
"email_source": "E-mail-kilde",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Drucken",
|
"print": "Drucken",
|
||||||
"view_source": "Quelltext anzeigen",
|
"view_source": "Quelltext anzeigen",
|
||||||
"export_email": "Als .eml exportieren",
|
"export_email": "Als .eml exportieren",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml oder .zip importieren",
|
"import_email": ".eml oder .zip importieren",
|
||||||
"keyboard_shortcuts": "Tastaturkürzel (?)",
|
"keyboard_shortcuts": "Tastaturkürzel (?)",
|
||||||
"email_source": "E-Mail-Quelltext",
|
"email_source": "E-Mail-Quelltext",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Print",
|
"print": "Print",
|
||||||
"view_source": "View source",
|
"view_source": "View source",
|
||||||
"export_email": "Export as .eml",
|
"export_email": "Export as .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Import .eml or .zip",
|
"import_email": "Import .eml or .zip",
|
||||||
"keyboard_shortcuts": "Keyboard shortcuts (?)",
|
"keyboard_shortcuts": "Keyboard shortcuts (?)",
|
||||||
"email_source": "Email Source",
|
"email_source": "Email Source",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Imprimir",
|
"print": "Imprimir",
|
||||||
"view_source": "Ver código fuente",
|
"view_source": "Ver código fuente",
|
||||||
"export_email": "Exportar como .eml",
|
"export_email": "Exportar como .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importar .eml o .zip",
|
"import_email": "Importar .eml o .zip",
|
||||||
"keyboard_shortcuts": "Atajos de teclado (?)",
|
"keyboard_shortcuts": "Atajos de teclado (?)",
|
||||||
"email_source": "Código Fuente del Correo",
|
"email_source": "Código Fuente del Correo",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "چاپ",
|
"print": "چاپ",
|
||||||
"view_source": "مشاهده منبع",
|
"view_source": "مشاهده منبع",
|
||||||
"export_email": "خروجی .eml",
|
"export_email": "خروجی .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "وارد کردن .eml یا .zip",
|
"import_email": "وارد کردن .eml یا .zip",
|
||||||
"keyboard_shortcuts": "میانبرهای صفحه کلید (?)",
|
"keyboard_shortcuts": "میانبرهای صفحه کلید (?)",
|
||||||
"email_source": "منبع ایمیل",
|
"email_source": "منبع ایمیل",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Imprimer",
|
"print": "Imprimer",
|
||||||
"view_source": "Voir la source",
|
"view_source": "Voir la source",
|
||||||
"export_email": "Exporter en .eml",
|
"export_email": "Exporter en .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importer .eml ou .zip",
|
"import_email": "Importer .eml ou .zip",
|
||||||
"keyboard_shortcuts": "Raccourcis clavier (?)",
|
"keyboard_shortcuts": "Raccourcis clavier (?)",
|
||||||
"email_source": "Source de l'email",
|
"email_source": "Source de l'email",
|
||||||
|
|||||||
@@ -245,6 +245,7 @@
|
|||||||
"print": "הדפס",
|
"print": "הדפס",
|
||||||
"view_source": "צפה במקור",
|
"view_source": "צפה במקור",
|
||||||
"export_email": "ייצא כ-.eml",
|
"export_email": "ייצא כ-.eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "ייבוא .eml",
|
"import_email": "ייבוא .eml",
|
||||||
"keyboard_shortcuts": "קיצורי מקשים (?)",
|
"keyboard_shortcuts": "קיצורי מקשים (?)",
|
||||||
"email_source": "מקור דוא\"ל",
|
"email_source": "מקור דוא\"ל",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Nyomtatás",
|
"print": "Nyomtatás",
|
||||||
"view_source": "Forrás megtekintése",
|
"view_source": "Forrás megtekintése",
|
||||||
"export_email": "Exportálás .eml-ként",
|
"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",
|
"import_email": "Importálás .eml vagy .zip fájlból",
|
||||||
"keyboard_shortcuts": "Billentyűparancsok (?)",
|
"keyboard_shortcuts": "Billentyűparancsok (?)",
|
||||||
"email_source": "E-mail forrás",
|
"email_source": "E-mail forrás",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Stampa",
|
"print": "Stampa",
|
||||||
"view_source": "Visualizza sorgente",
|
"view_source": "Visualizza sorgente",
|
||||||
"export_email": "Esporta come .eml",
|
"export_email": "Esporta come .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importa .eml o .zip",
|
"import_email": "Importa .eml o .zip",
|
||||||
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
|
"keyboard_shortcuts": "Scorciatoie da tastiera (?)",
|
||||||
"email_source": "Sorgente del messaggio",
|
"email_source": "Sorgente del messaggio",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "印刷",
|
"print": "印刷",
|
||||||
"view_source": "ソースを表示",
|
"view_source": "ソースを表示",
|
||||||
"export_email": ".emlとしてエクスポート",
|
"export_email": ".emlとしてエクスポート",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml または .zip をインポート",
|
"import_email": ".eml または .zip をインポート",
|
||||||
"keyboard_shortcuts": "キーボードショートカット (?)",
|
"keyboard_shortcuts": "キーボードショートカット (?)",
|
||||||
"email_source": "メールソース",
|
"email_source": "メールソース",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "인쇄",
|
"print": "인쇄",
|
||||||
"view_source": "원본 보기",
|
"view_source": "원본 보기",
|
||||||
"export_email": ".eml 파일로 내보내기",
|
"export_email": ".eml 파일로 내보내기",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml 또는 .zip 가져오기",
|
"import_email": ".eml 또는 .zip 가져오기",
|
||||||
"keyboard_shortcuts": "단축키 (?)",
|
"keyboard_shortcuts": "단축키 (?)",
|
||||||
"email_source": "메일 원본",
|
"email_source": "메일 원본",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Drukāt",
|
"print": "Drukāt",
|
||||||
"view_source": "Skatīt avota kodu",
|
"view_source": "Skatīt avota kodu",
|
||||||
"export_email": "Eksportēt kā .eml",
|
"export_email": "Eksportēt kā .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importēt .eml vai .zip",
|
"import_email": "Importēt .eml vai .zip",
|
||||||
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
|
"keyboard_shortcuts": "Īsinājumtaustiņi (?)",
|
||||||
"email_source": "Vēstules avota kods",
|
"email_source": "Vēstules avota kods",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Afdrukken",
|
"print": "Afdrukken",
|
||||||
"view_source": "Bron bekijken",
|
"view_source": "Bron bekijken",
|
||||||
"export_email": "Exporteren als .eml",
|
"export_email": "Exporteren als .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml of .zip importeren",
|
"import_email": ".eml of .zip importeren",
|
||||||
"keyboard_shortcuts": "Sneltoetsen (?)",
|
"keyboard_shortcuts": "Sneltoetsen (?)",
|
||||||
"email_source": "E-mailbron",
|
"email_source": "E-mailbron",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Drukuj",
|
"print": "Drukuj",
|
||||||
"view_source": "Pokaż źródło",
|
"view_source": "Pokaż źródło",
|
||||||
"export_email": "Eksportuj jako .eml",
|
"export_email": "Eksportuj jako .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importuj .eml lub .zip",
|
"import_email": "Importuj .eml lub .zip",
|
||||||
"keyboard_shortcuts": "Skróty klawiszowe (?)",
|
"keyboard_shortcuts": "Skróty klawiszowe (?)",
|
||||||
"email_source": "Źródło wiadomości",
|
"email_source": "Źródło wiadomości",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Imprimir",
|
"print": "Imprimir",
|
||||||
"view_source": "Ver código-fonte",
|
"view_source": "Ver código-fonte",
|
||||||
"export_email": "Exportar como .eml",
|
"export_email": "Exportar como .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importar .eml ou .zip",
|
"import_email": "Importar .eml ou .zip",
|
||||||
"keyboard_shortcuts": "Atalhos de teclado (?)",
|
"keyboard_shortcuts": "Atalhos de teclado (?)",
|
||||||
"email_source": "Código-fonte do E-mail",
|
"email_source": "Código-fonte do E-mail",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Imprimare",
|
"print": "Imprimare",
|
||||||
"view_source": "Vizualizați sursa",
|
"view_source": "Vizualizați sursa",
|
||||||
"export_email": "Exportați ca fișier .eml",
|
"export_email": "Exportați ca fișier .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importați fișiere .eml sau .zip",
|
"import_email": "Importați fișiere .eml sau .zip",
|
||||||
"keyboard_shortcuts": "Comenzi rapide de la tastatură (?)",
|
"keyboard_shortcuts": "Comenzi rapide de la tastatură (?)",
|
||||||
"email_source": "Sursa e-mailului",
|
"email_source": "Sursa e-mailului",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Распечатать",
|
"print": "Распечатать",
|
||||||
"view_source": "Просмотреть исходный код",
|
"view_source": "Просмотреть исходный код",
|
||||||
"export_email": "Экспортировать как .eml",
|
"export_email": "Экспортировать как .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Импортировать .eml или .zip",
|
"import_email": "Импортировать .eml или .zip",
|
||||||
"keyboard_shortcuts": "Сочетания клавиш (?)",
|
"keyboard_shortcuts": "Сочетания клавиш (?)",
|
||||||
"email_source": "Исходный код письма",
|
"email_source": "Исходный код письма",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Tlačiť",
|
"print": "Tlačiť",
|
||||||
"view_source": "Zobraziť zdrojový kód",
|
"view_source": "Zobraziť zdrojový kód",
|
||||||
"export_email": "Exportovať ako .eml",
|
"export_email": "Exportovať ako .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Importovať .eml alebo .zip",
|
"import_email": "Importovať .eml alebo .zip",
|
||||||
"keyboard_shortcuts": "Klávesové skratky (?)",
|
"keyboard_shortcuts": "Klávesové skratky (?)",
|
||||||
"email_source": "Zdrojový kód e-mailu",
|
"email_source": "Zdrojový kód e-mailu",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Yazdır",
|
"print": "Yazdır",
|
||||||
"view_source": "Kaynağı görüntüle",
|
"view_source": "Kaynağı görüntüle",
|
||||||
"export_email": ".eml olarak dışa aktar",
|
"export_email": ".eml olarak dışa aktar",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": ".eml veya .zip içe aktar",
|
"import_email": ".eml veya .zip içe aktar",
|
||||||
"keyboard_shortcuts": "Klavye kısayolları (?)",
|
"keyboard_shortcuts": "Klavye kısayolları (?)",
|
||||||
"email_source": "E-posta Kaynağı",
|
"email_source": "E-posta Kaynağı",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "Роздрукувати",
|
"print": "Роздрукувати",
|
||||||
"view_source": "Переглянути джерело",
|
"view_source": "Переглянути джерело",
|
||||||
"export_email": "Експортувати як .eml",
|
"export_email": "Експортувати як .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "Імпорт .eml або .zip",
|
"import_email": "Імпорт .eml або .zip",
|
||||||
"keyboard_shortcuts": "Комбінації клавіш (?)",
|
"keyboard_shortcuts": "Комбінації клавіш (?)",
|
||||||
"email_source": "Джерело електронної пошти",
|
"email_source": "Джерело електронної пошти",
|
||||||
|
|||||||
@@ -298,6 +298,7 @@
|
|||||||
"print": "打印",
|
"print": "打印",
|
||||||
"view_source": "查看源码",
|
"view_source": "查看源码",
|
||||||
"export_email": "导出为 .eml",
|
"export_email": "导出为 .eml",
|
||||||
|
"forward_as_attachment": "Forward as attachment",
|
||||||
"import_email": "导入 .eml 或 .zip",
|
"import_email": "导入 .eml 或 .zip",
|
||||||
"keyboard_shortcuts": "键盘快捷键(?)",
|
"keyboard_shortcuts": "键盘快捷键(?)",
|
||||||
"email_source": "邮件源码",
|
"email_source": "邮件源码",
|
||||||
|
|||||||
Reference in New Issue
Block a user