diff --git a/app/(main)/admin/_pages/policy.tsx b/app/(main)/admin/_pages/policy.tsx index 1f44bf6be..659aca2f0 100644 --- a/app/(main)/admin/_pages/policy.tsx +++ b/app/(main)/admin/_pages/policy.tsx @@ -54,6 +54,7 @@ const FEATURE_GATE_LABELS: Partial ``; +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: `${body}`, 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); + }); +}); diff --git a/components/email/structured-mail/sources.ts b/components/email/structured-mail/sources.ts new file mode 100644 index 000000000..5ebc2edac --- /dev/null +++ b/components/email/structured-mail/sources.ts @@ -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; + +/** Registered sources; each one appends itself here. */ +export const CARD_SOURCES: CardSource[] = []; diff --git a/components/email/structured-mail/use-structured-mail.ts b/components/email/structured-mail/use-structured-mail.ts new file mode 100644 index 000000000..510475842 --- /dev/null +++ b/components/email/structured-mail/use-structured-mail.ts @@ -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): Date { + return new Date(email.sentAt ?? email.receivedAt); +} + +async function resolveAll(cards: StructuredCard[]): Promise { + 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; +} diff --git a/components/icons/index.ts b/components/icons/index.ts index a53e8ebb6..a110750dd 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -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, diff --git a/lib/__tests__/authentication-results-on-receipt.test.ts b/lib/__tests__/authentication-results-on-receipt.test.ts new file mode 100644 index 000000000..130d0b0a4 --- /dev/null +++ b/lib/__tests__/authentication-results-on-receipt.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +type Call = [string, Record, 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 }, '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); + }); +}); diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 45c005261..52352c6f1 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -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'; diff --git a/lib/email-headers.ts b/lib/email-headers.ts index c4ed679f6..13b835baa 100644 --- a/lib/email-headers.ts +++ b/lib/email-headers.ts @@ -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 diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 5343977f4..ea94d4071 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -3047,10 +3047,13 @@ export class JMAPClient implements IJMAPClient { } private async parseEmailHeaders(email: Email): Promise { - const { parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers'); + const { authenticationResultsOnReceipt, parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers'); let headersRecord: Record; 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; diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index e5030b5ba..7d2c5c6e0 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -63,6 +63,8 @@ export interface Email { headers?: Record; // Security headers parsed authenticationResults?: AuthenticationResults; + /** See authenticationResultsOnReceipt (lib/email-headers.ts); set by the full message fetch. */ + authenticationResultsOnReceipt?: boolean; spamScore?: number; spamStatus?: string; spamLLM?: { diff --git a/lib/structured-mail-gate.ts b/lib/structured-mail-gate.ts new file mode 100644 index 000000000..56ec2d5d9 --- /dev/null +++ b/lib/structured-mail-gate.ts @@ -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')); +} diff --git a/lib/structured-mail/__tests__/trust.test.ts b/lib/structured-mail/__tests__/trust.test.ts new file mode 100644 index 000000000..ef393ec59 --- /dev/null +++ b/lib/structured-mail/__tests__/trust.test.ts @@ -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 = `

Thanks for your order.

`; + +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: '' }]; + 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: '' }, + { 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('

Thanks', '

---------- Forwarded message ----------

Thanks'); + expect(reason(mail({ ar: AR.pass, html: separator }))).toBe('forwarded'); + const outlook = 'FYI\n\nVon: Shop \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'); + }); +}); diff --git a/lib/structured-mail/trust.ts b/lib/structured-mail/trust.ts new file mode 100644 index 000000000..527dfffae --- /dev/null +++ b/lib/structured-mail/trust.ts @@ -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; + +/** + * 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): { 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 }; +} diff --git a/lib/subject-prefix.ts b/lib/subject-prefix.ts index e61e9f256..03bd65769 100644 --- a/lib/subject-prefix.ts +++ b/lib/subject-prefix.ts @@ -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;