fix: read a booking from its markup alone when the mail has some, not from its Wallet pass

This commit is contained in:
Linus Rath
2026-10-30 05:21:18 +01:00
parent f3e0b6c451
commit ba6d5e448b
5 changed files with 224 additions and 25 deletions
@@ -1,26 +1,70 @@
import { renderHook, waitFor } from '@testing-library/react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseAuthenticationResults } from '@/lib/email-headers';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, EmailBodyPart } from '@/lib/jmap/types';
import type { TransportBooking } from '@/lib/structured-mail';
import { MAIL_DATE, boardingPass, zipPass } from '@/lib/structured-mail/__tests__/pkpass-fixtures';
import { AIR_SEMANTICS, MAIL_DATE, TRAIN_SEMANTICS, boardingPass, zipPass } from '@/lib/structured-mail/__tests__/pkpass-fixtures';
import { usePolicyStore } from '@/stores/policy-store';
import { useStructuredMail } from '../use-structured-mail';
const PASS = 'mx.example.org; dkim=pass header.d=swiss.example; spf=pass smtp.mailfrom=bounce@swiss.example; dmarc=pass header.from=swiss.example';
const PLAIN = '<html><body><p>Your boarding pass is attached.</p></body></html>';
const ld = (...blocks: unknown[]) => `<html><head>${blocks.map((b) => `<script type="application/ld+json">${JSON.stringify(b)}</script>`).join('')}</head><body><p>Your boarding pass is attached.</p></body></html>`;
function mail(id: string, ar: string | null): Email {
/** The flight of AIR_SEMANTICS as the booking mail's markup. */
function flightMarkup(fields: { departureTime?: string; status?: string } = {}) {
const { departureTime = '2026-12-03T07:40:00+01:00', status = 'http://schema.org/ReservationConfirmed' } = fields;
return {
'@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'PK7Q2Z',
reservationStatus: status, underName: { '@type': 'Person', name: 'Jana Beispiel' },
reservationFor: {
'@type': 'Flight', flightNumber: 'LX1072', airline: { '@type': 'Airline', name: 'Swiss', iataCode: 'LX' },
departureAirport: { '@type': 'Airport', name: 'Zurich Airport', iataCode: 'ZRH' }, departureTime,
arrivalAirport: { '@type': 'Airport', name: 'Berlin Brandenburg', iataCode: 'BER' }, arrivalTime: '2026-12-03T09:05:00+01:00',
},
};
}
/** The train of TRAIN_SEMANTICS, as the markup writes it ("ICE597", full station names). */
const TRAIN_MARKUP = {
'@context': 'http://schema.org', '@type': 'TrainReservation', reservationNumber: 'ZUG4471', underName: { '@type': 'Person', name: 'Jana Beispiel' },
reservationFor: {
'@type': 'TrainTrip', trainNumber: 'ICE597', provider: { '@type': 'Organization', name: 'DB Fernverkehr' },
departureStation: { '@type': 'TrainStation', name: 'Hamburg Hauptbahnhof' }, departureTime: '2026-12-05T08:46:00+01:00',
arrivalStation: { '@type': 'TrainStation', name: 'Köln Hauptbahnhof' }, arrivalTime: '2026-12-05T12:50:00+01:00',
},
};
function mail(id: string, options: { ar?: string | null; html?: string | null; htmlLoaded?: boolean } = {}): Email {
const { ar = PASS, html = PLAIN, htmlLoaded = true } = options;
return {
id, threadId: 't', mailboxIds: {}, keywords: {}, size: 1, receivedAt: MAIL_DATE.toISOString(), subject: 'Your boarding pass',
from: [{ email: 'boarding@swiss.example' }],
...(ar ? { authenticationResults: parseAuthenticationResults([ar]), authenticationResultsOnReceipt: true } : {}),
htmlBody: [{ partId: '1', type: 'text/html' } as EmailBodyPart],
bodyValues: { '1': { value: '<html><body><p>Your boarding pass is attached.</p></body></html>', isTruncated: false, isEncodingProblem: false } },
htmlBody: [{ partId: '1', type: html === null ? 'text/plain' : 'text/html' } as EmailBodyPart],
bodyValues: htmlLoaded ? { '1': { value: html ?? 'Your boarding pass is attached.', isTruncated: false, isEncodingProblem: false } } : {},
attachments: [{ partId: '2', blobId: 'blob-pass', size: 3000, name: 'BoardingPass.pkpass', type: 'application/vnd.apple.pkpass' }],
} as unknown as Email;
}
async function passClient(semantics: Record<string, unknown> = AIR_SEMANTICS, transitType = 'PKTransitTypeAir') {
const bytes = await zipPass(boardingPass(semantics, transitType));
const fetchBlobArrayBuffer = vi.fn(async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer);
return { blob: { client: { fetchBlobArrayBuffer } as unknown as IJMAPClient, accountId: 'acc-1' }, fetchBlobArrayBuffer };
}
function gate(on: boolean) {
usePolicyStore.setState((state) => ({ policy: { ...state.policy, features: { ...state.policy.features, structuredMailEnabled: on } } }));
}
/** Let effects and the airport-table import settle when nothing is expected to show. */
async function settle() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
}
const initial = usePolicyStore.getState();
afterEach(() => {
@@ -28,14 +72,11 @@ afterEach(() => {
});
describe('useStructuredMail with a Wallet pass attached', () => {
it('shows the resolved flight card of a trusted mail, and downloads nothing for an untrusted one', async () => {
usePolicyStore.setState((state) => ({ policy: { ...state.policy, features: { ...state.policy.features, structuredMailEnabled: true } } }));
const bytes = await zipPass(boardingPass());
const fetchBlobArrayBuffer = vi.fn(async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer);
const client = { fetchBlobArrayBuffer } as unknown as IJMAPClient;
const untrusted = renderHook(() => useStructuredMail(mail('u', null), { client, accountId: 'acc-1' }));
const { result } = renderHook(() => useStructuredMail(mail('t', PASS), { client, accountId: 'acc-1' }));
it('shows the resolved flight card of a trusted mail without booking markup, and downloads nothing for an untrusted one', async () => {
gate(true);
const { blob, fetchBlobArrayBuffer } = await passClient();
const untrusted = renderHook(() => useStructuredMail(mail('u', { ar: null }), blob));
const { result } = renderHook(() => useStructuredMail(mail('t'), blob));
await waitFor(() => expect(result.current).toHaveLength(1));
const [card] = result.current as TransportBooking[];
expect(card).toMatchObject({ kind: 'flight', reference: 'PK7Q2Z', source: 'pkpass' });
@@ -45,4 +86,75 @@ describe('useStructuredMail with a Wallet pass attached', () => {
expect(fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-pass', 'BoardingPass.pkpass', 'application/vnd.apple.pkpass', 'acc-1', 5 * 1024 * 1024 + 1);
expect(untrusted.result.current).toEqual([]);
});
it('reads the pass of a plain-text mail too', async () => {
gate(true);
const { blob } = await passClient();
const { result } = renderHook(() => useStructuredMail(mail('p', { html: null }), blob));
await waitFor(() => expect(result.current).toMatchObject([{ kind: 'flight', source: 'pkpass' }]));
});
it('downloads nothing and shows nothing with the admin gate off', async () => {
gate(false);
const { blob, fetchBlobArrayBuffer } = await passClient();
const { result } = renderHook(() => useStructuredMail(mail('g'), blob));
await settle();
expect(result.current).toEqual([]);
expect(fetchBlobArrayBuffer).not.toHaveBeenCalled();
});
it('waits for the HTML before asking for passes, as it may hold booking markup', async () => {
gate(true);
const { blob, fetchBlobArrayBuffer } = await passClient();
const { result } = renderHook(() => useStructuredMail(mail('w', { htmlLoaded: false }), blob));
await settle();
expect(result.current).toEqual([]);
expect(fetchBlobArrayBuffer).not.toHaveBeenCalled();
});
describe('when the HTML has booking markup, the markup alone counts and nothing is downloaded', () => {
it('keeps a cancelled markup booking cancelled', async () => {
gate(true);
const { blob, fetchBlobArrayBuffer } = await passClient();
const { result } = renderHook(() => useStructuredMail(mail('c', { html: ld(flightMarkup({ status: 'http://schema.org/ReservationCancelled' })) }), blob));
await waitFor(() => expect(result.current).toHaveLength(1));
await settle();
expect(result.current).toMatchObject([{ kind: 'flight', status: 'cancelled', source: 'json-ld' }]);
expect(fetchBlobArrayBuffer).not.toHaveBeenCalled();
});
it('shows nothing when the markup booking was rejected', async () => {
gate(true);
const { blob, fetchBlobArrayBuffer } = await passClient();
const html = ld(flightMarkup(), flightMarkup({ departureTime: '2026-12-03T08:10:00+01:00' }));
const { result } = renderHook(() => useStructuredMail(mail('r', { html }), blob));
await settle();
expect(result.current).toEqual([]);
expect(fetchBlobArrayBuffer).not.toHaveBeenCalled();
});
it('shows one train card with the markup leg when the pass writes number and station differently', async () => {
gate(true);
const { blob, fetchBlobArrayBuffer } = await passClient(TRAIN_SEMANTICS, 'PKTransitTypeTrain');
const { result } = renderHook(() => useStructuredMail(mail('i', { html: ld(TRAIN_MARKUP) }), blob));
await waitFor(() => expect(result.current).toHaveLength(1));
await settle();
const cards = result.current as TransportBooking[];
expect(cards).toHaveLength(1);
expect(cards[0].legs).toHaveLength(1);
expect(cards[0].legs[0]).toMatchObject({ number: 'ICE597', departure: { place: { name: 'Hamburg Hauptbahnhof' } } });
expect(fetchBlobArrayBuffer).not.toHaveBeenCalled();
});
it('shows one flight card when the pass has no confirmation number', async () => {
gate(true);
const { confirmationNumber: _ref, ...noRef } = AIR_SEMANTICS;
const { blob, fetchBlobArrayBuffer } = await passClient(noRef);
const { result } = renderHook(() => useStructuredMail(mail('f', { html: ld(flightMarkup()) }), blob));
await waitFor(() => expect(result.current).toHaveLength(1));
await settle();
expect(result.current).toMatchObject([{ kind: 'flight', source: 'json-ld', reference: 'PK7Q2Z' }]);
expect(fetchBlobArrayBuffer).not.toHaveBeenCalled();
});
});
});
@@ -6,7 +6,7 @@ import {
loadAirportZones,
mergeCandidates,
needsAirportZones,
readStructuredMail,
readStructuredMailDetailed,
resolveCards,
type StructuredCard,
} from '@/lib/structured-mail';
@@ -35,32 +35,39 @@ async function resolveAll(cards: StructuredCard[]): Promise<StructuredCard[]> {
* 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.
* once read, so a card never shows with unchecked times. A message whose
* markup describes a booking (even one validation rejected) is read from the
* markup alone: the sources aren't asked, so nothing is downloaded and a pass
* can't revive a rejected or cancelled booking or show one booking twice.
*/
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;
// An HTML part whose text hasn't loaded yet may still hold booking markup.
const htmlPending = html === undefined && !!email?.htmlBody?.some((part) => part.type === 'text/html');
const markup = useMemo(() => {
if (!trusted || !email || !html) return [];
return readStructuredMail(html, { mailDate: mailDateOf(email) });
const { cards: markup, hadBookingMarkup } = useMemo(() => {
if (!trusted || !email || !html) return { cards: [] as StructuredCard[], hadBookingMarkup: false };
return readStructuredMailDetailed(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 askSources = !hadBookingMarkup && !htmlPending;
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}`;
const settleKey = `${email?.id ?? ''}|${trusted}|${markup.length}|${askSources}`;
useEffect(() => {
if (!trusted || !email) return;
const needsWork = needsAirportZones(markup) || CARD_SOURCES.length > 0;
const sources = askSources ? CARD_SOURCES : [];
const needsWork = needsAirportZones(markup) || 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) =>
const fromSources = (await Promise.all(sources.map((source) =>
source({ email, mailDate: mailDateOf(email), blob, signal: controller.signal }).catch(() => [] as StructuredCard[]),
))).flat();
const combined = fromSources.length > 0
@@ -70,7 +77,7 @@ export function useStructuredMail(email: Email | null | undefined, blobSource?:
if (!controller.signal.aborted) setSettled({ key: settleKey, cards });
})();
return () => controller.abort();
// markup and settleKey change together with the message.
// markup, askSources and settleKey change together with the message.
}, [trusted, markup, settleKey]); // eslint-disable-line react-hooks/exhaustive-deps
if (!trusted) return [];
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { readStructuredMail, readStructuredMailDetailed } from '..';
const CTX = { mailDate: new Date('2026-11-20T08:00:00Z') };
const page = (...blocks: unknown[]) => `<html><head>${blocks.map((b) => `<script type="application/ld+json">${JSON.stringify(b)}</script>`).join('')}</head><body><p>Hello</p></body></html>`;
function flight(departureTime: string, status = 'http://schema.org/ReservationConfirmed') {
return {
'@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'PK7Q2Z', reservationStatus: status,
underName: { '@type': 'Person', name: 'Jana Beispiel' },
reservationFor: {
'@type': 'Flight', flightNumber: '1072', airline: { '@type': 'Airline', name: 'Swiss', iataCode: 'LX' },
departureAirport: { '@type': 'Airport', iataCode: 'ZRH' }, departureTime,
arrivalAirport: { '@type': 'Airport', iataCode: 'BER' }, arrivalTime: '2026-12-03T09:05:00+01:00',
},
};
}
const ORDER = { '@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-77', merchant: 'Shop' };
describe('readStructuredMailDetailed', () => {
it('reports booking markup for a valid booking, with the same cards as readStructuredMail', () => {
const html = page(flight('2026-12-03T07:40:00+01:00'));
const detailed = readStructuredMailDetailed(html, CTX);
expect(detailed.hadBookingMarkup).toBe(true);
expect(detailed.cards).toEqual(readStructuredMail(html, CTX));
expect(detailed.cards).toHaveLength(1);
});
it('reports booking markup for a cancelled booking', () => {
const detailed = readStructuredMailDetailed(page(flight('2026-12-03T07:40:00+01:00', 'http://schema.org/ReservationCancelled')), CTX);
expect(detailed).toMatchObject({ hadBookingMarkup: true, cards: [{ status: 'cancelled' }] });
});
it('reports booking markup that validation or merging rejected', () => {
const conflicting = readStructuredMailDetailed(page(flight('2026-12-03T07:40:00+01:00'), flight('2026-12-03T08:10:00+01:00')), CTX);
expect(conflicting).toEqual({ cards: [], hadBookingMarkup: true });
const incomplete = readStructuredMailDetailed(page(flight('2026-12-03')), CTX);
expect(incomplete).toEqual({ cards: [], hadBookingMarkup: true });
});
it('reports booking markup for reservation types it has no card for', () => {
const boat = { '@context': 'https://schema.org', '@type': 'BoatReservation', reservationNumber: 'B-1' };
expect(readStructuredMailDetailed(page(boat), CTX)).toEqual({ cards: [], hadBookingMarkup: true });
});
it('reports none for shipments only, foreign contexts and plain mail', () => {
expect(readStructuredMailDetailed(page(ORDER), CTX)).toMatchObject({ hadBookingMarkup: false, cards: [{ kind: 'shipment' }] });
expect(readStructuredMailDetailed(page({ ...flight('2026-12-03T07:40:00+01:00'), '@context': 'http://example.org' }), CTX).hadBookingMarkup).toBe(false);
expect(readStructuredMailDetailed('<html><body>Your Reservation is attached.</body></html>', CTX)).toEqual({ cards: [], hadBookingMarkup: false });
});
});
+23 -3
View File
@@ -11,7 +11,7 @@
*/
import { extractStructuredData } from './extract';
import { mergeCandidates } from './merge';
import { normalizeItems } from './normalize';
import { hasReservation, normalizeItems } from './normalize';
import type { StructuredCard } from './types';
import { validateCard } from './validate';
@@ -21,13 +21,33 @@ export interface ReadOptions {
parseHtml?: (html: string) => Document;
}
export interface DetailedRead {
/** Validated cards, merged to one per booking, zones not yet resolved. */
cards: StructuredCard[];
/**
* Whether the markup holds any reservation, valid or rejected. Such a mail
* is read from its markup alone: a pass must not bring back a booking the
* markup rejected or cancelled, nor add a second card for it.
*/
hadBookingMarkup: boolean;
}
/** readStructuredMail, plus whether the markup described a booking at all. */
export function readStructuredMailDetailed(html: string, options: ReadOptions): DetailedRead {
const extracted = extractStructuredData(html, options.parseHtml);
const cards = normalizeItems(extracted);
return {
cards: mergeCandidates(cards.map((card) => validateCard(card, options.mailDate))),
hadBookingMarkup: hasReservation(extracted),
};
}
/**
* Cards from one HTML body: validated and merged to one card per booking,
* with zones not yet resolved (resolveCards).
*/
export function readStructuredMail(html: string, options: ReadOptions): StructuredCard[] {
const cards = normalizeItems(extractStructuredData(html, options.parseHtml));
return mergeCandidates(cards.map((card) => validateCard(card, options.mailDate)));
return readStructuredMailDetailed(html, options).cards;
}
export { extractStructuredData, mayContainStructuredData } from './extract';
+9
View File
@@ -388,6 +388,15 @@ export function normalizeItems(extracted: ExtractedItem[]): StructuredCard[] {
return cards;
}
/**
* Whether the markup holds a reservation of any type (known or not, valid or
* not) under a schema.org context. A mail that describes its booking in
* markup is read from the markup alone; its Wallet passes are not consulted.
*/
export function hasReservation(extracted: ExtractedItem[]): boolean {
return flatten(extracted).some(({ object }) => typesOf(object).some((t) => /Reservation$/.test(t)));
}
/** The merge key: equal keys describe the same booking or order. */
export function cardKey(card: StructuredCard): string {
switch (card.kind) {