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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user