diff --git a/components/email/__tests__/phishing-report-flow.test.tsx b/components/email/__tests__/phishing-report-flow.test.tsx
index 7802efea7..9d1b9f4e6 100644
--- a/components/email/__tests__/phishing-report-flow.test.tsx
+++ b/components/email/__tests__/phishing-report-flow.test.tsx
@@ -1,10 +1,11 @@
-import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email } from '@/lib/jmap/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { usePolicyStore } from '@/stores/policy-store';
import { useToastStore } from '@/stores/toast-store';
+import { usePhishingReportStore } from '@/stores/phishing-report-store';
// The report flow through the real dialog: it names the address, Cancel
// sends nothing, each message gets its own report, each moves to Junk with
@@ -27,6 +28,7 @@ const hoisted = vi.hoisted(() => {
copyBlobs: async () => ({}),
sendEmail: vi.fn(async () => ({ scheduled: false })),
submitRawEmail: vi.fn(async () => ({ scheduled: false })),
+ fetchBlobArrayBuffer: vi.fn(async () => new TextEncoder().encode('Subject: x' + String.fromCharCode(13, 10, 13, 10) + 'hi').buffer),
markAsSpam: vi.fn(async () => {}),
undoSpam: vi.fn(async () => {}),
};
@@ -54,9 +56,9 @@ vi.mock('@/stores/email-store', () => ({
const { PhishingReportHost } = await import('../phishing-report-host');
const { requestPhishingReport } = await import('@/hooks/use-phishing-report');
-function setAddress(address: string) {
+function setAddress(address: string, format: 'forward' | 'arf' = 'forward') {
usePolicyStore.setState({
- policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address, format: 'forward' } } },
+ policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address, format } } },
});
}
@@ -68,6 +70,7 @@ afterEach(() => {
cleanup();
usePolicyStore.setState({ policy: DEFAULT_POLICY });
useToastStore.setState({ toasts: [] });
+ usePhishingReportStore.setState({ pending: null, busy: false, opener: null });
vi.clearAllMocks();
});
@@ -127,6 +130,18 @@ describe('Report phishing', () => {
expect(hoisted.client.undoSpam).toHaveBeenCalledWith('e1', 'inbox', undefined, ['$phishing']);
});
+ it('sends an ARF report raw when the admin picked ARF', async () => {
+ setAddress('abuse@example.org', 'arf');
+ render();
+ act(() => requestPhishingReport([e1], 'inbox'));
+ await act(async () => { fireEvent.click(screen.getByTestId('phishing-report-confirm')); });
+ await waitFor(() => expect(hoisted.client.submitRawEmail).toHaveBeenCalledTimes(1));
+ expect(hoisted.client.sendEmail).not.toHaveBeenCalled();
+ const raw = await ((hoisted.client.submitRawEmail.mock.calls[0] as unknown[])[0] as Blob).text();
+ expect(raw).toContain('report-type=feedback-report');
+ await waitFor(() => expect(hoisted.store.markAsSpam).toHaveBeenCalledTimes(1));
+ });
+
it('skips messages of your own folders', () => {
render();
act(() => requestPhishingReport([e1], 'sent'));
diff --git a/lib/__tests__/phishing-report-arf.test.ts b/lib/__tests__/phishing-report-arf.test.ts
new file mode 100644
index 000000000..0e0a811ba
--- /dev/null
+++ b/lib/__tests__/phishing-report-arf.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { Email, Identity } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import { buildArfParts, buildArfReportBlob } from '../phishing-report-arf';
+import { sendPhishingReport } from '../phishing-report';
+
+// The ARF report (RFC 5965): multipart/report with report-type
+// feedback-report, a text part, message/feedback-report with
+// Feedback-Type: fraud, User-Agent and Version: 1, and the original whole
+// as message/rfc822; submitted raw without a Sent copy.
+
+const email = {
+ id: 'e1', blobId: 'blob-1', size: 40, subject: 'Konto gesperrt – jetzt prüfen', receivedAt: '2026-10-01T10:00:00Z',
+ from: [{ email: 'bad@phish.example' }], keywords: {}, mailboxIds: { inbox: true },
+} as unknown as Email;
+const reporter = { id: 'id-me', name: 'Alice', email: 'alice@example.org' } as Identity;
+const ORIGINAL = 'From: bad@phish.example\r\nSubject: x\r\n\r\nclick here\r\n';
+
+describe('buildArfParts', () => {
+ it('has the three parts and the required feedback fields', () => {
+ const { head, tail } = buildArfParts({ email, reporter, to: 'abuse@example.org', eightBit: false, now: new Date('2026-11-01T08:00:00Z') });
+ expect(head).toMatch(/^Date: Sun, 01 Nov 2026 08:00:00 \+0000\r\n/);
+ expect(head).toContain('From: Alice \r\n');
+ expect(head).toContain('To: abuse@example.org\r\n');
+ // Non-ASCII subject as an encoded word, after "Phishing:".
+ expect(head).toMatch(/\r\nSubject: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=\r\n/);
+ const subject = /Subject: =\?UTF-8\?B\?([^?]+)\?=/.exec(head)![1];
+ expect(new TextDecoder().decode(Uint8Array.from(atob(subject), (c) => c.charCodeAt(0)))).toBe('Phishing: Konto gesperrt – jetzt prüfen');
+ expect(head).toContain('Content-Type: multipart/report; report-type=feedback-report;');
+ const boundary = /boundary="([^"]+)"/.exec(head)![1];
+ expect(head.split(`--${boundary}\r\n`)).toHaveLength(4);
+ expect(head).toContain('Content-Type: message/feedback-report\r\n');
+ expect(head).toContain('\r\nFeedback-Type: fraud\r\n');
+ expect(head).toMatch(/\r\nUser-Agent: Bulwark\/\S+\r\n/);
+ expect(head).toContain('\r\nVersion: 1\r\n');
+ expect(head).toContain('\r\nArrival-Date: Thu, 01 Oct 2026 10:00:00 +0000\r\n');
+ expect(head).toContain('\r\nOriginal-Rcpt-To: alice@example.org\r\n');
+ expect(head).toContain('\r\nReported-Domain: phish.example\r\n');
+ expect(head).toMatch(/Content-Type: message\/rfc822\r\n[\s\S]*Content-Transfer-Encoding: 7bit\r\n\r\n$/);
+ expect(tail).toBe(`\r\n--${boundary}--\r\n`);
+ });
+
+ it('marks an 8-bit original as 8bit, never base64', () => {
+ const { head } = buildArfParts({ email, reporter, to: 'abuse@example.org', eightBit: true });
+ expect(head).toMatch(/Content-Transfer-Encoding: 8bit\r\n\r\n$/);
+ });
+});
+
+describe('an ARF report', () => {
+ it('carries the original byte for byte and is submitted raw, from the original account', async () => {
+ const client = {
+ getIdentities: vi.fn(async () => [reporter]),
+ getUsername: () => 'alice@example.org',
+ getAccountId: () => 'acc-own',
+ fetchBlobArrayBuffer: vi.fn(async () => new TextEncoder().encode(ORIGINAL).buffer),
+ submitRawEmail: vi.fn(async (..._args: unknown[]) => ({ scheduled: false })),
+ sendEmail: vi.fn(),
+ };
+ await sendPhishingReport(email, {
+ client: client as unknown as IJMAPClient,
+ sourceAccountId: 'acc-group',
+ setup: { address: 'abuse@example.org', format: 'arf' },
+ buildArf: buildArfReportBlob,
+ });
+ expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'message.eml', 'message/rfc822', 'acc-group');
+ expect(client.sendEmail).not.toHaveBeenCalled();
+ const [blob, identityId, delayed, envelope] = client.submitRawEmail.mock.calls[0] as [Blob, string, unknown, string[]];
+ expect(identityId).toBe('id-me');
+ expect(delayed).toBeUndefined();
+ expect(envelope).toEqual(['abuse@example.org']);
+ const text = await blob.text();
+ const boundary = /boundary="([^"]+)"/.exec(text)![1];
+ expect(text).toContain(`message/rfc822\r\nContent-Disposition: attachment; filename="reported-message.eml"\r\nContent-Transfer-Encoding: 7bit\r\n\r\n${ORIGINAL}--${boundary}--\r\n`);
+ });
+});
diff --git a/lib/mdn.ts b/lib/mdn.ts
index 5e05ab0d6..c255e4944 100644
--- a/lib/mdn.ts
+++ b/lib/mdn.ts
@@ -40,7 +40,7 @@ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** RFC 5322 date in UTC, e.g. "Thu, 28 May 2026 14:23:00 +0000". */
-function rfc5322Date(d: Date = new Date()): string {
+export function rfc5322Date(d: Date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${DAYS[d.getUTCDay()]}, ${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` +
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`;
@@ -55,7 +55,7 @@ function utf8ToBase64(value: string): string {
}
/** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */
-function base64Body(text: string): string {
+export function base64Body(text: string): string {
return (utf8ToBase64(text).match(/.{1,76}/g) || []).join("\r\n");
}
@@ -71,13 +71,13 @@ function base64Body(text: string): string {
* append headers and a body of their choosing to a message the victim's own
* account submits (GHSA-w38p-hpqv-g89c).
*/
-function stripHeaderControls(value: string): string {
+export function stripHeaderControls(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\x00-\x1F\x7F]+/g, " ").trim();
}
/** Address-like atoms (mailboxes, message-ids) may contain no whitespace at all. */
-function headerAtom(value: string): string {
+export function headerAtom(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\x00-\x20\x7F]+/g, "");
}
@@ -86,14 +86,14 @@ function headerAtom(value: string): string {
* Header value safe to place after "Name: ". Control characters are stripped
* first; the result is RFC 2047-encoded when it contains non-ASCII.
*/
-function encodeHeaderWord(value: string): string {
+export function encodeHeaderWord(value: string): string {
const clean = stripHeaderControls(value);
if (!/[^\x20-\x7E]/.test(clean)) return clean;
return `=?UTF-8?B?${utf8ToBase64(clean)}?=`;
}
/** Display-name for a mailbox: encoded-word if non-ASCII, quoted if it uses specials. */
-function displayName(value: string): string {
+export function displayName(value: string): string {
const encoded = encodeHeaderWord(value);
if (encoded.startsWith("=?") || !/[()<>[\]:;@\\,."]/.test(encoded)) return encoded;
return `"${encoded.replace(/["\\]/g, "\\$&")}"`;
@@ -109,7 +109,7 @@ function ensureAngles(messageId: string | string[] | undefined): string {
return trimmed.startsWith("<") ? trimmed : `<${trimmed}>`;
}
-function randomToken(): string {
+export function randomToken(): string {
const rnd = Math.random().toString(36).slice(2);
return `${Date.now().toString(36)}.${rnd}`;
}
diff --git a/lib/phishing-report-arf.ts b/lib/phishing-report-arf.ts
new file mode 100644
index 000000000..e2424778f
--- /dev/null
+++ b/lib/phishing-report-arf.ts
@@ -0,0 +1,117 @@
+/**
+ * The ARF format of a phishing report (RFC 5965, an admin option of
+ * Organisation > Security > Phishing reports): a multipart/report with
+ * report-type=feedback-report and three parts, a short text for people, the
+ * machine-readable message/feedback-report (Feedback-Type: fraud, which
+ * RFC 5965 §7.3 defines as "fraud or phishing"), and the original whole and
+ * unchanged as message/rfc822.
+ *
+ * JMAP can't set the report-type parameter through Email/set, so this is
+ * raw MIME: the original's bytes are downloaded, wrapped and uploaded, then
+ * submitted with submitRawEmail (no Sent copy). The upload counts against
+ * the server's upload quota and is limited by maxSizeUpload.
+ */
+
+import type { Email } from '@/lib/jmap/types';
+import { base64Body, displayName, encodeHeaderWord, headerAtom, randomToken, rfc5322Date } from '@/lib/mdn';
+import { reportSubject, type ArfInput } from './phishing-report';
+
+export interface ArfParts {
+ /** Everything before the original's bytes. */
+ head: string;
+ /** Everything after them. */
+ tail: string;
+}
+
+function appVersion(): string {
+ return (process.env.NEXT_PUBLIC_APP_VERSION || '').trim() || 'unknown';
+}
+
+/** "Reported-Domain": the domain of the original's From, when it has one. */
+function senderDomain(email: Pick): string | null {
+ const address = email.from?.[0]?.email ?? '';
+ const domain = address.slice(address.lastIndexOf('@') + 1);
+ return address.includes('@') && /^[A-Za-z0-9.-]+$/.test(domain) ? domain.toLowerCase() : null;
+}
+
+/**
+ * The report around the original. `eightBit`: the original has bytes
+ * outside 7-bit ASCII (message/rfc822 may not be base64-encoded, RFC 2046
+ * §5.2.1, so its part says 8bit then).
+ */
+export function buildArfParts(input: {
+ email: Pick;
+ reporter: { name?: string; email: string };
+ to: string;
+ eightBit: boolean;
+ now?: Date;
+}): ArfParts {
+ const { email, reporter } = input;
+ const fromEmail = headerAtom(reporter.email);
+ const domain = fromEmail.split('@')[1] || 'localhost';
+ const boundary = `----=_ARF_${randomToken()}`;
+ const fromName = reporter.name?.trim() ? displayName(reporter.name.trim()) : '';
+ const arrival = email.receivedAt ? new Date(email.receivedAt) : null;
+ const reportedDomain = senderDomain(email);
+ const human = [
+ `This is a phishing report (RFC 5965) sent by ${fromEmail} with Bulwark.`,
+ '',
+ 'The reported message is attached unchanged.',
+ ].join('\r\n');
+ const report = [
+ 'Feedback-Type: fraud',
+ `User-Agent: Bulwark/${appVersion()}`,
+ 'Version: 1',
+ ...(arrival && !Number.isNaN(arrival.getTime()) ? [`Arrival-Date: ${rfc5322Date(arrival)}`] : []),
+ `Original-Rcpt-To: ${fromEmail}`,
+ ...(reportedDomain ? [`Reported-Domain: ${reportedDomain}`] : []),
+ ].join('\r\n');
+
+ const head = [
+ `Date: ${rfc5322Date(input.now ?? new Date())}`,
+ `From: ${fromName ? `${fromName} <${fromEmail}>` : fromEmail}`,
+ `To: ${headerAtom(input.to)}`,
+ `Subject: ${encodeHeaderWord(reportSubject(email.subject))}`,
+ `Message-ID: `,
+ 'MIME-Version: 1.0',
+ 'Content-Type: multipart/report; report-type=feedback-report;',
+ `\tboundary="${boundary}"`,
+ '',
+ `--${boundary}`,
+ 'Content-Type: text/plain; charset=utf-8',
+ 'Content-Transfer-Encoding: base64',
+ '',
+ base64Body(human),
+ '',
+ `--${boundary}`,
+ 'Content-Type: message/feedback-report',
+ 'Content-Transfer-Encoding: 7bit',
+ '',
+ report,
+ '',
+ `--${boundary}`,
+ 'Content-Type: message/rfc822',
+ 'Content-Disposition: attachment; filename="reported-message.eml"',
+ `Content-Transfer-Encoding: ${input.eightBit ? '8bit' : '7bit'}`,
+ '',
+ '',
+ ].join('\r\n');
+ const tail = `\r\n--${boundary}--\r\n`;
+ return { head, tail };
+}
+
+function hasEightBit(bytes: Uint8Array): boolean {
+ for (let i = 0; i < bytes.length; i++) if (bytes[i] > 0x7f) return true;
+ return false;
+}
+
+/** The whole report as a Blob, the original downloaded from its own account. */
+export async function buildArfReportBlob(input: ArfInput): Promise {
+ const { client, email, sourceAccountId } = input;
+ if (!email.blobId) throw new Error('The message has no content to report');
+ const original = new Uint8Array(await client.fetchBlobArrayBuffer(email.blobId, 'message.eml', 'message/rfc822', sourceAccountId));
+ const parts = buildArfParts({ email, reporter: input.reporter, to: input.to, eightBit: hasEightBit(original) });
+ // The original goes in byte for byte; the closing boundary needs a line break before it.
+ const endsWithBreak = original.length >= 2 && original[original.length - 2] === 0x0d && original[original.length - 1] === 0x0a;
+ return new Blob([parts.head, original, endsWithBreak ? parts.tail.slice(2) : parts.tail], { type: 'message/rfc822' });
+}
diff --git a/lib/phishing-report-flow.ts b/lib/phishing-report-flow.ts
index 0ac3bdf8b..b894c525a 100644
--- a/lib/phishing-report-flow.ts
+++ b/lib/phishing-report-flow.ts
@@ -51,9 +51,10 @@ export interface PhishingReportOutcome {
moved: MovedReport[];
}
-// ARF arrives in the next step; until then an 'arf' setup fails the report.
-async function loadArfBuilder(): Promise<((input: ArfInput) => Promise) | undefined> {
- return undefined;
+// Loaded only when an admin picked the ARF format.
+async function loadArfBuilder(): Promise<(input: ArfInput) => Promise> {
+ const { buildArfReportBlob } = await import('./phishing-report-arf');
+ return buildArfReportBlob;
}
/** The folder a message is in, for a move back that the store can't do. */