mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
feat: show booking cards only for mail from verified senders
This commit is contained in:
@@ -54,6 +54,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
customFromEnabled: { label: 'Custom From Address', description: 'Let users type any sender address in the composer instead of picking one of their identities, and let replies to a catch-all address on their domain send from that address. Turn this off when users may only send as their configured identities.' },
|
||||
archiveActionEnabled: { label: 'Archive Action', description: 'Offer "Archive" on messages: the toolbar button, the context menu and selection bar, the hover and swipe actions, the E shortcut and archiving after an export. Turn this off when mail is archived elsewhere, for example by a mail gateway; existing Archive folders stay as ordinary folders and Bulwark no longer creates one.' },
|
||||
tagsAboveSharedEnabled: { label: 'Tags Above Shared Mailboxes', description: 'List the Tags in the sidebar above the shared mailboxes instead of below them. Turn this on when users have many shared mailboxes and work with tags: expanded, the shared mailboxes push the tags out of sight.' },
|
||||
structuredMailEnabled: { label: 'Booking and Parcel Cards', description: 'Show a card above booking, shipping and order mails that carry schema.org markup (flights, trains, buses, hotels, rental cars, events, restaurants, parcels, orders), with copy buttons, links and "Add to calendar". Cards appear only for mail whose sender passed SPF or DKIM for the From domain, and nothing is added to a calendar without a click. Everything is read in the browser; no request goes to airlines or carriers. Turn this off if mail content should not be turned into calendar entries, or if you don\'t trust the Authentication-Results your mail server writes.' },
|
||||
};
|
||||
|
||||
const PAGE = adminPageDef('policy');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { parseAuthenticationResults } from '@/lib/email-headers';
|
||||
import type { Email, EmailBodyPart } from '@/lib/jmap/types';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { CARD_SOURCES } from '../sources';
|
||||
import { useStructuredMail } from '../use-structured-mail';
|
||||
|
||||
const ld = (value: unknown) => `<script type="application/ld+json">${JSON.stringify(value)}</script>`;
|
||||
const ORDER = { '@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop' };
|
||||
const FLIGHT = {
|
||||
'@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'QX7K2M', underName: 'Erika Mustermann',
|
||||
reservationFor: {
|
||||
'@type': 'Flight', flightNumber: '400', airline: { name: 'Lufthansa', iataCode: 'LH' },
|
||||
departureAirport: { iataCode: 'FRA' }, departureTime: '2026-12-01T10:15:00+01:00',
|
||||
arrivalAirport: { iataCode: 'JFK' }, arrivalTime: '2026-12-01T13:05:00-05:00',
|
||||
},
|
||||
};
|
||||
const PASS = 'mx.example.org; dkim=pass header.d=shop.example; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example';
|
||||
|
||||
function mail(id: string, body: string, ar: string | null = PASS): Email {
|
||||
return {
|
||||
id, threadId: 't', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-30T09:00:00Z', subject: 'Booking',
|
||||
from: [{ email: 'orders@shop.example' }],
|
||||
...(ar ? { authenticationResults: parseAuthenticationResults([ar]), authenticationResultsOnReceipt: true } : {}),
|
||||
htmlBody: [{ partId: '1', type: 'text/html' } as EmailBodyPart],
|
||||
bodyValues: { '1': { value: `<html><head>${body}</head><body></body></html>`, isTruncated: false, isEncodingProblem: false } },
|
||||
} as unknown as Email;
|
||||
}
|
||||
|
||||
const initial = usePolicyStore.getState();
|
||||
function gate(on: boolean) {
|
||||
usePolicyStore.setState((state) => ({ policy: { ...state.policy, features: { ...state.policy.features, structuredMailEnabled: on } } }));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
usePolicyStore.setState(initial, true);
|
||||
CARD_SOURCES.length = 0;
|
||||
});
|
||||
|
||||
describe('useStructuredMail', () => {
|
||||
it('returns the cards of a trusted message', () => {
|
||||
gate(true);
|
||||
const { result } = renderHook(() => useStructuredMail(mail('a', ld(ORDER))));
|
||||
expect(result.current).toMatchObject([{ kind: 'shipment', orderNumber: 'SO-1' }]);
|
||||
});
|
||||
|
||||
it('returns nothing for an untrusted message or with the admin gate off', () => {
|
||||
gate(true);
|
||||
expect(renderHook(() => useStructuredMail(mail('b', ld(ORDER), null))).result.current).toEqual([]);
|
||||
gate(false);
|
||||
expect(renderHook(() => useStructuredMail(mail('c', ld(ORDER)))).result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows a flight only once the airport zones are resolved', async () => {
|
||||
gate(true);
|
||||
const { result } = renderHook(() => useStructuredMail(mail('d', ld(FLIGHT))));
|
||||
expect(result.current).toEqual([]);
|
||||
await waitFor(() => expect(result.current).toHaveLength(1));
|
||||
expect(result.current[0]).toMatchObject({ kind: 'flight', legs: [{ departure: { at: { zone: 'Europe/Berlin' } } }] });
|
||||
});
|
||||
|
||||
it('merges cards from attachment sources, and never asks them about untrusted mail', async () => {
|
||||
gate(true);
|
||||
let asked = 0;
|
||||
CARD_SOURCES.push(async () => {
|
||||
asked++;
|
||||
return [{ kind: 'shipment', key: 'shipment|order:so-2', orderNumber: 'SO-2', merchant: 'Shop', items: [], parcels: [], source: 'pkpass' }];
|
||||
});
|
||||
const { result } = renderHook(() => useStructuredMail(mail('e', ld(ORDER))));
|
||||
await waitFor(() => expect(result.current).toHaveLength(2));
|
||||
renderHook(() => useStructuredMail(mail('f', ld(ORDER), null)));
|
||||
expect(asked).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import type { StructuredCard } from '@/lib/structured-mail';
|
||||
import type { EmailBlobContext } from '@/stores/email-store';
|
||||
|
||||
/**
|
||||
* Card sources beyond the HTML markup, read asynchronously once a trusted
|
||||
* message is open (Apple Wallet passes among its attachments). Each gets the
|
||||
* message and returns cards that are validated but not zone-resolved; the
|
||||
* hook merges them with the markup cards and resolves zones for all. A
|
||||
* source must read only `email.attachments` (never parts unwrapped from an
|
||||
* attached message) and must not throw.
|
||||
*/
|
||||
export interface CardSourceContext {
|
||||
email: Email;
|
||||
mailDate: Date;
|
||||
/** Client and JMAP account the message's blobs live in. */
|
||||
blob: EmailBlobContext;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export type CardSource = (context: CardSourceContext) => Promise<StructuredCard[]>;
|
||||
|
||||
/** Registered sources; each one appends itself here. */
|
||||
export const CARD_SOURCES: CardSource[] = [];
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import {
|
||||
loadAirportZones,
|
||||
mergeCandidates,
|
||||
needsAirportZones,
|
||||
readStructuredMail,
|
||||
resolveCards,
|
||||
type StructuredCard,
|
||||
} from '@/lib/structured-mail';
|
||||
import { getStructuredMailTrust, structuredHtmlPart } from '@/lib/structured-mail/trust';
|
||||
import { useStructuredMailEnabled } from '@/lib/structured-mail-gate';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { resolveEmailBlobContext, type EmailBlobContext } from '@/stores/email-store';
|
||||
import { CARD_SOURCES } from './sources';
|
||||
|
||||
function mailDateOf(email: Pick<Email, 'sentAt' | 'receivedAt'>): Date {
|
||||
return new Date(email.sentAt ?? email.receivedAt);
|
||||
}
|
||||
|
||||
async function resolveAll(cards: StructuredCard[]): Promise<StructuredCard[]> {
|
||||
if (!needsAirportZones(cards)) return resolveCards(cards);
|
||||
try {
|
||||
return resolveCards(cards, await loadAirportZones());
|
||||
} catch {
|
||||
// Without the table no flight card can be checked, so none is shown.
|
||||
return resolveCards(cards);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking, parcel and order cards of an open message: empty unless the
|
||||
* admin gate is on and the message passes getStructuredMailTrust. Cards that
|
||||
* need nothing asynchronous show at once; flights wait for the airport time
|
||||
* zone table, and cards from attachment sources (CARD_SOURCES) are merged in
|
||||
* once read, so a card never shows with unchecked times.
|
||||
*/
|
||||
export function useStructuredMail(email: Email | null | undefined, blobSource?: EmailBlobContext): StructuredCard[] {
|
||||
const enabled = useStructuredMailEnabled();
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const trusted = useMemo(() => !!email && enabled && getStructuredMailTrust(email).trusted, [email, enabled]);
|
||||
const html = email ? structuredHtmlPart(email)?.value : undefined;
|
||||
|
||||
const markup = useMemo(() => {
|
||||
if (!trusted || !email || !html) return [];
|
||||
return readStructuredMail(html, { mailDate: mailDateOf(email) });
|
||||
// email.id and the HTML identify the input; the rest of the object may change identity freely.
|
||||
}, [trusted, email?.id, html]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const immediate = useMemo(() => resolveCards(markup.filter((card) => card.kind !== 'flight')), [markup]);
|
||||
const [settled, setSettled] = useState<{ key: string; cards: StructuredCard[] } | null>(null);
|
||||
const settleKey = `${email?.id ?? ''}|${trusted}|${markup.length}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!trusted || !email) return;
|
||||
const needsWork = needsAirportZones(markup) || CARD_SOURCES.length > 0;
|
||||
if (!needsWork) return;
|
||||
const controller = new AbortController();
|
||||
const blob = blobSource ?? resolveEmailBlobContext(email, client);
|
||||
(async () => {
|
||||
const fromSources = (await Promise.all(CARD_SOURCES.map((source) =>
|
||||
source({ email, mailDate: mailDateOf(email), blob, signal: controller.signal }).catch(() => [] as StructuredCard[]),
|
||||
))).flat();
|
||||
const combined = fromSources.length > 0
|
||||
? mergeCandidates([...markup, ...fromSources].map((card) => ({ card, problems: [] })))
|
||||
: markup;
|
||||
const cards = await resolveAll(combined);
|
||||
if (!controller.signal.aborted) setSettled({ key: settleKey, cards });
|
||||
})();
|
||||
return () => controller.abort();
|
||||
// markup and settleKey change together with the message.
|
||||
}, [trusted, markup, settleKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!trusted) return [];
|
||||
return settled?.key === settleKey ? settled.cards : immediate;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export {
|
||||
IconBan as Ban,
|
||||
IconRosetteDiscountCheckFilled as BadgeCheck,
|
||||
IconBaseline as Baseline,
|
||||
IconBed as Bed,
|
||||
IconBell as Bell,
|
||||
IconBellOff as BellOff,
|
||||
IconBellRinging as BellRinging,
|
||||
@@ -48,6 +49,7 @@ export {
|
||||
IconBug as Bug,
|
||||
IconBuilding as Building,
|
||||
IconBuilding as Building2,
|
||||
IconBus as Bus,
|
||||
IconCake as Cake,
|
||||
IconCalendar as Calendar,
|
||||
IconCalendarUp as CalendarArrowUp,
|
||||
@@ -55,8 +57,10 @@ export {
|
||||
IconCalendarClock as CalendarClock,
|
||||
IconCalendarMonth as CalendarDays,
|
||||
IconCalendarEvent as CalendarEvent,
|
||||
IconCalendarPlus as CalendarPlus,
|
||||
IconCalendarX as CalendarX,
|
||||
IconCamera as Camera,
|
||||
IconCar as Car,
|
||||
IconCheck as Check,
|
||||
IconChecks as CheckCheck,
|
||||
IconCircleCheck as CheckCircle,
|
||||
@@ -175,6 +179,9 @@ export {
|
||||
IconLayoutSidebarRightExpand as PanelRightOpen,
|
||||
IconPaperclip as Paperclip,
|
||||
IconPlayerPause as Pause,
|
||||
IconPlane as Plane,
|
||||
IconToolsKitchen2 as Restaurant,
|
||||
IconShoppingBag as ShoppingBag,
|
||||
IconPlayerStop as Stop,
|
||||
IconPencil as PenLine,
|
||||
IconEdit as PenSquare,
|
||||
@@ -234,9 +241,12 @@ export {
|
||||
IconTag as Tag,
|
||||
IconTags as Tags,
|
||||
IconTerminal as Terminal,
|
||||
IconTicket as Ticket,
|
||||
IconTrain as Train,
|
||||
IconTrash as Trash,
|
||||
IconTrash as Trash2,
|
||||
IconAlertTriangle as TriangleAlert,
|
||||
IconTruck as Truck,
|
||||
IconTypography as Type,
|
||||
IconUnderline as Underline,
|
||||
IconArrowBack as Undo,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
|
||||
type Call = [string, Record<string, unknown>, string];
|
||||
|
||||
const AR = ' mx.example.org; dkim=pass header.d=shop.example; dmarc=pass header.from=shop.example';
|
||||
const RECEIVED = ' from mail.shop.example by mx.example.org (Stalwart SMTP) with ESMTPS id 1';
|
||||
|
||||
function clientReturning(headers: { name: string; value: string }[]) {
|
||||
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
||||
Object.assign(client, { accountId: 'me', capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 50 } } });
|
||||
vi.spyOn(client as unknown as { request: (c: Call[]) => Promise<unknown> }, 'request')
|
||||
.mockImplementation(async (calls: Call[]) => ({
|
||||
methodResponses: calls.map(([method, , id]) => [method, {
|
||||
list: [{ id: 'm1', threadId: 't1', receivedAt: '2026-10-30T09:00:00Z', from: [{ email: 'orders@shop.example' }], headers }],
|
||||
}, id]),
|
||||
}));
|
||||
return client;
|
||||
}
|
||||
|
||||
// Booking cards need the order of Received and Authentication-Results, which
|
||||
// the header record built by getEmail no longer has.
|
||||
describe('getEmail', () => {
|
||||
it('records that a receiving server wrote the Authentication-Results', async () => {
|
||||
const email = await clientReturning([{ name: 'Received', value: RECEIVED }, { name: 'Authentication-Results', value: AR }]).getEmail('m1');
|
||||
expect(email?.authenticationResultsOnReceipt).toBe(true);
|
||||
expect(email?.authenticationResults).toMatchObject({ dkim: { result: 'pass' } });
|
||||
});
|
||||
|
||||
it('records a header with no Received above it as not written on receipt', async () => {
|
||||
const email = await clientReturning([{ name: 'Authentication-Results', value: AR }, { name: 'Received', value: RECEIVED }]).getEmail('m1');
|
||||
expect(email?.authenticationResultsOnReceipt).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,8 @@ export interface FeatureGates {
|
||||
archiveActionEnabled: boolean;
|
||||
/** The sidebar lists the Tags above the shared mailboxes instead of below them. */
|
||||
tagsAboveSharedEnabled: boolean;
|
||||
/** Booking, parcel and order cards read from schema.org markup in mail. */
|
||||
structuredMailEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
@@ -114,6 +116,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
customFromEnabled: true,
|
||||
archiveActionEnabled: true,
|
||||
tagsAboveSharedEnabled: false,
|
||||
structuredMailEnabled: true,
|
||||
};
|
||||
|
||||
export type ColorMode = 'light' | 'dark' | 'system';
|
||||
|
||||
@@ -133,6 +133,34 @@ export function getSenderVerification(
|
||||
return { status: 'unverified', domain, sentFrom };
|
||||
}
|
||||
|
||||
/**
|
||||
* `with` protocols (RFC 3848) that record an authenticated submission: the
|
||||
* client handed the message in after logging in, so whatever
|
||||
* Authentication-Results it carries below that hop are the client's own text.
|
||||
*/
|
||||
const SUBMISSION_PROTOCOL_RE = /\bwith\s+(?:UTF8)?(?:E?SMTP|LMTP)S?A\b/i;
|
||||
|
||||
/**
|
||||
* Whether the topmost Authentication-Results header was written by a server
|
||||
* that received the message, rather than by whoever composed it. Stalwart
|
||||
* keeps an Authentication-Results header a signed-in user puts into a mail
|
||||
* they submit, and adds no Received header to mail submitted over JMAP, so a
|
||||
* forged "dkim=pass" can sit on top. A receiving server writes its Received
|
||||
* line first and its Authentication-Results below it (Stalwart and Gmail
|
||||
* both do), so: a Received header must sit above the topmost
|
||||
* Authentication-Results, and the nearest one must not record an
|
||||
* authenticated submission. Takes the headers in message order.
|
||||
*/
|
||||
export function authenticationResultsOnReceipt(headers: readonly { name: string; value: string }[]): boolean {
|
||||
const index = headers.findIndex((header) => header.name.toLowerCase() === 'authentication-results');
|
||||
if (index < 0) return false;
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
if (headers[i].name.toLowerCase() !== 'received') continue;
|
||||
return !SUBMISSION_PROTOCOL_RE.test(headers[i].value);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The From domain whose BIMI logo this message may show, or undefined. BIMI
|
||||
* needs a DMARC pass for the very domain in From: an aligned SPF or DKIM
|
||||
|
||||
+4
-1
@@ -3047,10 +3047,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
private async parseEmailHeaders(email: Email): Promise<void> {
|
||||
const { parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers');
|
||||
const { authenticationResultsOnReceipt, parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers');
|
||||
|
||||
let headersRecord: Record<string, string | string[]>;
|
||||
if (Array.isArray(email.headers)) {
|
||||
// The record below loses the order between different header names,
|
||||
// which the receipt check needs.
|
||||
email.authenticationResultsOnReceipt = authenticationResultsOnReceipt(email.headers as unknown as JMAPEmailHeader[]);
|
||||
headersRecord = {};
|
||||
for (const header of email.headers as unknown as JMAPEmailHeader[]) {
|
||||
if (!header?.name || !header?.value) continue;
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface Email {
|
||||
headers?: Record<string, string | string[]>;
|
||||
// Security headers parsed
|
||||
authenticationResults?: AuthenticationResults;
|
||||
/** See authenticationResultsOnReceipt (lib/email-headers.ts); set by the full message fetch. */
|
||||
authenticationResultsOnReceipt?: boolean;
|
||||
spamScore?: number;
|
||||
spamStatus?: string;
|
||||
spamLLM?: {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
|
||||
// The admin policy's `structuredMailEnabled` gate. Off: no booking, parcel or
|
||||
// order cards, no parsing of markup and no .pkpass download.
|
||||
|
||||
/** Whether booking cards are shown, for components. */
|
||||
export function useStructuredMailEnabled(): boolean {
|
||||
return usePolicyStore((s) => s.isFeatureEnabled('structuredMailEnabled'));
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { authenticationResultsOnReceipt, parseAuthenticationResults } from '@/lib/email-headers';
|
||||
import type { Email, EmailBodyPart } from '@/lib/jmap/types';
|
||||
import { hasForwardPrefix } from '@/lib/subject-prefix';
|
||||
import { readStructuredMail } from '..';
|
||||
import { getStructuredMailTrust, isPersonalMailboxDomain } from '../trust';
|
||||
|
||||
const HTML = `<html><head><script type="application/ld+json">${JSON.stringify({
|
||||
'@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', orderStatus: 'OrderProcessing',
|
||||
})}</script></head><body><p>Thanks for your order.</p></body></html>`;
|
||||
|
||||
const AR = {
|
||||
pass: 'mx.example.org; dkim=pass header.d=shop.example header.s=s1; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example',
|
||||
fail: 'mx.example.org; dkim=fail header.d=shop.example; spf=fail smtp.mailfrom=shop.example; dmarc=fail header.from=shop.example',
|
||||
unverified: 'mx.example.org; dkim=pass header.d=bulk-sender.example; spf=pass smtp.mailfrom=bounce@bulk-sender.example; dmarc=none',
|
||||
};
|
||||
|
||||
type Headers = { name: string; value: string }[];
|
||||
|
||||
function mail(o: {
|
||||
from?: string; ar?: string; headers?: Headers; subject?: string; html?: string; text?: string;
|
||||
truncated?: boolean; bodyStructure?: EmailBodyPart; onReceipt?: boolean;
|
||||
} = {}): Email {
|
||||
const from = o.from ?? 'orders@shop.example';
|
||||
const headers: Headers = o.headers ?? [
|
||||
{ name: 'Received', value: 'from mail.shop.example (mail.shop.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1' },
|
||||
...(o.ar ? [{ name: 'Authentication-Results', value: o.ar }] : []),
|
||||
{ name: 'From', value: `<${from}>` },
|
||||
];
|
||||
const arValues = headers.filter((h) => h.name.toLowerCase() === 'authentication-results').map((h) => h.value);
|
||||
return {
|
||||
id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-30T09:00:00Z',
|
||||
from: [{ email: from }], subject: o.subject ?? 'Your order SO-1',
|
||||
headers: headers as unknown as Email['headers'],
|
||||
...(arValues.length > 0 ? { authenticationResults: parseAuthenticationResults(arValues) } : {}),
|
||||
...(o.onReceipt !== undefined ? { authenticationResultsOnReceipt: o.onReceipt } : {}),
|
||||
htmlBody: [{ partId: '2', type: 'text/html' } as EmailBodyPart],
|
||||
textBody: o.text !== undefined ? [{ partId: '1', type: 'text/plain' } as EmailBodyPart] : [],
|
||||
bodyValues: {
|
||||
'2': { value: o.html ?? HTML, isTruncated: !!o.truncated, isEncodingProblem: false },
|
||||
...(o.text !== undefined ? { '1': { value: o.text, isTruncated: false, isEncodingProblem: false } } : {}),
|
||||
},
|
||||
...(o.bodyStructure ? { bodyStructure: o.bodyStructure } : {}),
|
||||
} as unknown as Email;
|
||||
}
|
||||
|
||||
/** What the reader does: the trust gate, then the parser. */
|
||||
function cardsFor(email: Email) {
|
||||
if (!getStructuredMailTrust(email).trusted) return [];
|
||||
const html = email.bodyValues!['2'].value;
|
||||
return readStructuredMail(html, { mailDate: new Date(email.receivedAt) });
|
||||
}
|
||||
|
||||
const reason = (email: Email) => {
|
||||
const trust = getStructuredMailTrust(email);
|
||||
return trust.trusted ? 'trusted' : trust.reason;
|
||||
};
|
||||
|
||||
describe('the same markup under each Authentication-Results verdict', () => {
|
||||
it('gives a card only when SPF or DKIM passes for the From domain', () => {
|
||||
expect(cardsFor(mail({ ar: AR.pass }))).toHaveLength(1);
|
||||
expect(cardsFor(mail({ ar: AR.fail }))).toEqual([]);
|
||||
expect(cardsFor(mail({ ar: AR.unverified }))).toEqual([]);
|
||||
expect(cardsFor(mail({}))).toEqual([]);
|
||||
// Internal: a colleague on the reader's own domain, handed in without any header.
|
||||
expect(cardsFor(mail({ from: 'travel@example.org' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('names the reason', () => {
|
||||
expect(reason(mail({ ar: AR.pass }))).toBe('trusted');
|
||||
expect(reason(mail({ ar: AR.fail }))).toBe('sender_not_verified');
|
||||
expect(reason(mail({ ar: AR.unverified }))).toBe('sender_not_verified');
|
||||
expect(reason(mail({}))).toBe('no_authentication_results');
|
||||
});
|
||||
|
||||
it('accepts a DKIM pass from a subdomain of the From domain (Uber signs as ibt.uber.com)', () => {
|
||||
const ar = 'mx.google.com; dkim=pass header.i=@ibt.shop.example header.s=smtp3; spf=pass smtp.mailfrom=bounces@ibt.shop.example; dmarc=pass header.from=shop.example';
|
||||
expect(reason(mail({ ar }))).toBe('trusted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication-Results written by the sender', () => {
|
||||
it('refuses a passing header with no Received above it (submitted over JMAP)', () => {
|
||||
const headers = [{ name: 'Authentication-Results', value: AR.pass }, { name: 'From', value: '<orders@shop.example>' }];
|
||||
expect(reason(mail({ headers }))).toBe('not_written_on_receipt');
|
||||
});
|
||||
|
||||
it('refuses a passing header right below an authenticated submission hop', () => {
|
||||
const headers = [
|
||||
{ name: 'Received', value: 'from [10.0.0.5] (user@example.org) by mx.example.org with ESMTPSA id 7' },
|
||||
{ name: 'Authentication-Results', value: AR.pass },
|
||||
];
|
||||
expect(reason(mail({ headers }))).toBe('not_written_on_receipt');
|
||||
});
|
||||
|
||||
it('trusts the flag the full message fetch recorded', () => {
|
||||
expect(reason(mail({ ar: AR.pass, onReceipt: false }))).toBe('not_written_on_receipt');
|
||||
expect(reason(mail({ ar: AR.pass, headers: [{ name: 'Authentication-Results', value: AR.pass }], onReceipt: true }))).toBe('trusted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticationResultsOnReceipt', () => {
|
||||
it('accepts the header order Stalwart writes on delivery', () => {
|
||||
expect(authenticationResultsOnReceipt([
|
||||
{ name: 'Delivered-To', value: 'info@example.org' },
|
||||
{ name: 'X-Spam-Status', value: 'No' },
|
||||
{ name: 'Received', value: 'from smh-01.example.net (smh-01.example.net [178.254.0.201]) (using TLSv1.3 with cipher TLS13_AES_256_GCM_SHA384) by mail.example.org (Stalwart SMTP) with ESMTPS id 1' },
|
||||
{ name: 'Authentication-Results', value: 'mail.example.org; dkim=pass header.d=shop.example' },
|
||||
{ name: 'Received-SPF', value: 'pass' },
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the header order Gmail writes', () => {
|
||||
expect(authenticationResultsOnReceipt([
|
||||
{ name: 'Delivered-To', value: 'someone@gmail.com' },
|
||||
{ name: 'Received', value: 'by 2002:a05:6a10:1b4a:b0:5b1:2::1 with SMTP id 10csp1;' },
|
||||
{ name: 'X-Received', value: 'by 2002:a17:906 with SMTP id 1' },
|
||||
{ name: 'ARC-Authentication-Results', value: 'i=1; mx.google.com; dkim=pass' },
|
||||
{ name: 'Return-Path', value: '<bounce@shop.example>' },
|
||||
{ name: 'Received', value: 'from mail.shop.example (mail.shop.example. [192.0.2.1]) by mx.google.com with ESMTPS id 2' },
|
||||
{ name: 'Received-SPF', value: 'pass' },
|
||||
{ name: 'Authentication-Results', value: 'mx.google.com; dkim=pass header.i=@shop.example' },
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses headers above every Received, after a submission hop, or with no Received at all', () => {
|
||||
expect(authenticationResultsOnReceipt([{ name: 'Authentication-Results', value: 'x' }, { name: 'Received', value: 'from a by b with ESMTPS' }])).toBe(false);
|
||||
expect(authenticationResultsOnReceipt([{ name: 'Received', value: 'from a by b with LMTPSA' }, { name: 'Authentication-Results', value: 'x' }])).toBe(false);
|
||||
expect(authenticationResultsOnReceipt([{ name: 'Received', value: 'from a by b with utf8esmtpa' }, { name: 'Authentication-Results', value: 'x' }])).toBe(false);
|
||||
expect(authenticationResultsOnReceipt([{ name: 'From', value: 'x' }, { name: 'Authentication-Results', value: 'x' }])).toBe(false);
|
||||
expect(authenticationResultsOnReceipt([{ name: 'Received', value: 'from a by b with ESMTPS' }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forwarded and personal mail', () => {
|
||||
it('gives no card for a forward prefix anywhere in the prefix chain', () => {
|
||||
expect(reason(mail({ ar: AR.pass, subject: 'Fwd: Your order SO-1' }))).toBe('forwarded');
|
||||
expect(reason(mail({ ar: AR.pass, subject: 'Re: WG: Ihre Bestellung' }))).toBe('forwarded');
|
||||
expect(reason(mail({ ar: AR.pass, subject: 'Re: Your order SO-1' }))).toBe('trusted');
|
||||
});
|
||||
|
||||
it('gives no card when the body carries a forward separator or Outlook header block', () => {
|
||||
const separator = HTML.replace('<p>Thanks', '<p>---------- Forwarded message ----------</p><p>Thanks');
|
||||
expect(reason(mail({ ar: AR.pass, html: separator }))).toBe('forwarded');
|
||||
const outlook = 'FYI\n\nVon: Shop <orders@shop.example>\nGesendet: Freitag, 30. Oktober 2026 10:00\nAn: Erika\nBetreff: Ihre Bestellung\n';
|
||||
expect(reason(mail({ ar: AR.pass, text: outlook }))).toBe('forwarded');
|
||||
expect(reason(mail({ ar: AR.pass, text: 'From: Frankfurt\nTo: New York\nDate: 1 Dec' }))).toBe('trusted');
|
||||
});
|
||||
|
||||
it('gives no card for mail from a personal mailbox, even when it passes', () => {
|
||||
const ar = 'mx.example.org; dkim=pass header.d=gmail.com; spf=pass smtp.mailfrom=friend@gmail.com; dmarc=pass header.from=gmail.com';
|
||||
expect(reason(mail({ from: 'friend@gmail.com', ar }))).toBe('personal_mailbox');
|
||||
expect(isPersonalMailboxDomain('outlook.de')).toBe(true);
|
||||
expect(isPersonalMailboxDomain('yahoo.co.uk')).toBe(true);
|
||||
expect(isPersonalMailboxDomain('lufthansa.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects forward prefixes on their own', () => {
|
||||
expect(hasForwardPrefix('FW: x')).toBe(true);
|
||||
expect(hasForwardPrefix('AW: Antw: Fwd: x')).toBe(true);
|
||||
expect(hasForwardPrefix('Forwarding your booking')).toBe(false);
|
||||
expect(hasForwardPrefix(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attached and truncated mail', () => {
|
||||
it('gives no card when the HTML part sits inside an attached message', () => {
|
||||
const bodyStructure = {
|
||||
type: 'multipart/mixed', subParts: [
|
||||
{ partId: '1', type: 'text/plain' },
|
||||
{ type: 'message/rfc822', partId: '3', subParts: [{ partId: '2', type: 'text/html' }] },
|
||||
],
|
||||
} as unknown as EmailBodyPart;
|
||||
expect(reason(mail({ ar: AR.pass, bodyStructure }))).toBe('attached_message');
|
||||
});
|
||||
|
||||
it('gives no card when the HTML body value was cut off', () => {
|
||||
expect(reason(mail({ ar: AR.pass, truncated: true }))).toBe('truncated');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Whether a message may show booking cards at all. A card looks official and
|
||||
* a calendar entry outlives the mail, so the bar is high: the receiving
|
||||
* server must have tied the mail to its From domain, the mail must not be a
|
||||
* forward, and the markup must come from the message itself. Plain
|
||||
* TypeScript on the Email shape; the admin gate is checked by the caller.
|
||||
*/
|
||||
import {
|
||||
authenticationResultsOnReceipt,
|
||||
getSenderVerification,
|
||||
hasAlignedAuthenticationPass,
|
||||
} from '@/lib/email-headers';
|
||||
import type { Email, EmailBodyPart } from '@/lib/jmap/types';
|
||||
import { hasForwardPrefix } from '@/lib/subject-prefix';
|
||||
|
||||
export type UntrustedReason =
|
||||
| 'no_authentication_results'
|
||||
| 'not_written_on_receipt'
|
||||
| 'sender'
|
||||
| 'sender_not_verified'
|
||||
| 'personal_mailbox'
|
||||
| 'forwarded'
|
||||
| 'attached_message'
|
||||
| 'truncated';
|
||||
|
||||
export type StructuredMailTrust = { trusted: true } | { trusted: false; reason: UntrustedReason };
|
||||
|
||||
export type TrustInput = Pick<Email, 'from' | 'subject' | 'authenticationResults' | 'authenticationResultsOnReceipt' | 'headers' | 'htmlBody' | 'textBody' | 'bodyValues' | 'bodyStructure'>;
|
||||
|
||||
/**
|
||||
* Personal mailbox providers. A booking mail from one of these is someone
|
||||
* forwarding it (perhaps with the "Fwd:" removed); airlines and shops don't
|
||||
* send from them.
|
||||
*/
|
||||
const PERSONAL_MAILBOX_DOMAINS = [
|
||||
'gmail.com', 'googlemail.com', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'mac.com',
|
||||
'web.de', 't-online.de', 'freenet.de', 'posteo.de', 'posteo.net', 'mailbox.org', 'proton.me',
|
||||
'protonmail.com', 'protonmail.ch', 'pm.me', 'mail.ru', 'gmx.com', 'gmx.net', 'gmx.de', 'gmx.at', 'gmx.ch',
|
||||
'tutanota.com', 'tuta.io', 'zoho.com', 'fastmail.com', 'hey.com',
|
||||
];
|
||||
const PERSONAL_MAILBOX_FAMILIES = /^(?:outlook|hotmail|live|yahoo|ymail|yandex)\.[a-z.]+$/;
|
||||
|
||||
export function isPersonalMailboxDomain(domain: string): boolean {
|
||||
const d = domain.toLowerCase();
|
||||
return PERSONAL_MAILBOX_DOMAINS.includes(d) || PERSONAL_MAILBOX_FAMILIES.test(d);
|
||||
}
|
||||
|
||||
/** Separator lines mail clients put above a forwarded message. */
|
||||
const FORWARD_SEPARATOR_RE = new RegExp([
|
||||
'forwarded message', 'begin forwarded message', 'original message', 'weitergeleitete nachricht',
|
||||
'ursprüngliche nachricht', 'message transféré', 'message d\'origine', 'mensaje reenviado', 'mensaje original',
|
||||
'messaggio inoltrato', 'messaggio originale', 'doorgestuurd bericht', 'oorspronkelijk bericht', 'mensagem encaminhada',
|
||||
].map((s) => `-{2,}\\s*${s}\\s*-{2,}|^\\s*${s}:?\\s*$`).join('|'), 'im');
|
||||
|
||||
/** Outlook's forward header block: From / Sent / To ... Subject, in a few languages. */
|
||||
const OUTLOOK_HEADER_RE = /^\s*(?:from|von|de|da|van|od):[^\n]*\n\s*(?:sent|gesendet|envoyé|inviato|enviado|verzonden|wysłano):[^\n]*\n(?:[^\n]*\n){0,3}\s*(?:subject|betreff|objet|oggetto|asunto|onderwerp|temat):/im;
|
||||
|
||||
function htmlToLines(html: string): string {
|
||||
return html
|
||||
.replace(/<(?:script|style)[\s\S]*?<\/(?:script|style)>/gi, ' ')
|
||||
.replace(/<(?:br|\/p|\/div|\/tr|\/li|\/h\d|\/td)\b[^>]*>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/[ \t]+/g, ' ');
|
||||
}
|
||||
|
||||
function looksForwarded(text: string): boolean {
|
||||
return FORWARD_SEPARATOR_RE.test(text) || OUTLOOK_HEADER_RE.test(text);
|
||||
}
|
||||
|
||||
function partValue(email: TrustInput, part: EmailBodyPart | undefined): { value: string; truncated: boolean } | undefined {
|
||||
if (!part?.partId) return undefined;
|
||||
const value = email.bodyValues?.[part.partId];
|
||||
return value ? { value: value.value ?? '', truncated: !!value.isTruncated } : undefined;
|
||||
}
|
||||
|
||||
/** The HTML part a card reads: the message's own first text/html part. */
|
||||
export function structuredHtmlPart(email: Pick<Email, 'htmlBody' | 'bodyValues'>): { value: string; truncated: boolean } | undefined {
|
||||
const part = email.htmlBody?.find((p) => p.type === 'text/html');
|
||||
if (!part?.partId) return undefined;
|
||||
const value = email.bodyValues?.[part.partId];
|
||||
return value ? { value: value.value ?? '', truncated: !!value.isTruncated } : undefined;
|
||||
}
|
||||
|
||||
/** Whether `partId` sits inside an attached message (message/rfc822) in the body tree. */
|
||||
function insideAttachedMessage(root: EmailBodyPart | undefined, partId: string | undefined): boolean {
|
||||
if (!root || !partId) return false;
|
||||
const walk = (part: EmailBodyPart, inMessage: boolean): boolean | undefined => {
|
||||
const nested = inMessage || (part !== root && part.type?.toLowerCase() === 'message/rfc822');
|
||||
if (part.partId === partId) return nested;
|
||||
for (const child of part.subParts ?? []) {
|
||||
const found = walk(child, nested);
|
||||
if (found !== undefined) return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return walk(root, false) ?? false;
|
||||
}
|
||||
|
||||
function headerList(headers: TrustInput['headers']): { name: string; value: string }[] | undefined {
|
||||
return Array.isArray(headers) ? (headers as unknown as { name: string; value: string }[]) : undefined;
|
||||
}
|
||||
|
||||
export function getStructuredMailTrust(email: TrustInput): StructuredMailTrust {
|
||||
const auth = email.authenticationResults;
|
||||
if (!auth) return { trusted: false, reason: 'no_authentication_results' };
|
||||
|
||||
const list = headerList(email.headers);
|
||||
const onReceipt = email.authenticationResultsOnReceipt ?? (list ? authenticationResultsOnReceipt(list) : false);
|
||||
if (!onReceipt) return { trusted: false, reason: 'not_written_on_receipt' };
|
||||
|
||||
const senders = email.from ?? [];
|
||||
const from = senders[0]?.email ?? '';
|
||||
const domain = from.slice(from.lastIndexOf('@') + 1).toLowerCase();
|
||||
if (senders.length !== 1 || !from.includes('@') || !domain.includes('.')) return { trusted: false, reason: 'sender' };
|
||||
|
||||
if (getSenderVerification(auth, from) !== null || !hasAlignedAuthenticationPass(auth, from)) {
|
||||
return { trusted: false, reason: 'sender_not_verified' };
|
||||
}
|
||||
if (isPersonalMailboxDomain(domain)) return { trusted: false, reason: 'personal_mailbox' };
|
||||
|
||||
const html = structuredHtmlPart(email);
|
||||
const htmlPartId = email.htmlBody?.find((p) => p.type === 'text/html')?.partId;
|
||||
if (insideAttachedMessage(email.bodyStructure, htmlPartId)) return { trusted: false, reason: 'attached_message' };
|
||||
if (html?.truncated) return { trusted: false, reason: 'truncated' };
|
||||
|
||||
if (hasForwardPrefix(email.subject)) return { trusted: false, reason: 'forwarded' };
|
||||
const text = partValue(email, email.textBody?.find((p) => p.type === 'text/plain'));
|
||||
if ((html && looksForwarded(htmlToLines(html.value))) || (text && looksForwarded(text.value))) {
|
||||
return { trusted: false, reason: 'forwarded' };
|
||||
}
|
||||
return { trusted: true };
|
||||
}
|
||||
@@ -85,6 +85,27 @@ const ANY_PREFIX_RE = buildPrefixRegex([...REPLY_TOKENS, ...FORWARD_TOKENS]);
|
||||
* stripSubjectPrefixes("foo") === "foo"
|
||||
* stripSubjectPrefixes("") === ""
|
||||
*/
|
||||
const FORWARD_RE = buildPrefixRegex(FORWARD_TOKENS);
|
||||
const REPLY_RE = buildPrefixRegex(REPLY_TOKENS);
|
||||
|
||||
/**
|
||||
* Whether the subject's leading prefix chain marks a forward anywhere in it
|
||||
* ("Fwd:", "Re: Fwd:", "AW: WG:"), case-insensitively. Booking cards use it:
|
||||
* a forwarded booking mail was authenticated for the forwarder, not for the
|
||||
* airline or shop it quotes.
|
||||
*/
|
||||
export function hasForwardPrefix(subject: string | undefined | null): boolean {
|
||||
let rest = (subject ?? "").trimStart();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const forward = FORWARD_RE.exec(rest);
|
||||
if (forward && forward[0].length > 0) return true;
|
||||
const reply = REPLY_RE.exec(rest);
|
||||
if (!reply || reply[0].length === 0) return false;
|
||||
rest = rest.slice(reply[0].length).trimStart();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stripSubjectPrefixes(subject: string | undefined | null): string {
|
||||
if (!subject) return "";
|
||||
let s = subject;
|
||||
|
||||
Reference in New Issue
Block a user