diff --git a/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.tsx b/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.tsx index 35ee5c7ed..6b07da243 100644 --- a/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.tsx +++ b/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.tsx @@ -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 = '

Your boarding pass is attached.

'; +const ld = (...blocks: unknown[]) => `${blocks.map((b) => ``).join('')}

Your boarding pass is attached.

`; -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: '

Your boarding pass is attached.

', 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 = 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(); + }); + }); }); diff --git a/components/email/structured-mail/use-structured-mail.ts b/components/email/structured-mail/use-structured-mail.ts index 510475842..3f39e9255 100644 --- a/components/email/structured-mail/use-structured-mail.ts +++ b/components/email/structured-mail/use-structured-mail.ts @@ -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 { * 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 []; diff --git a/lib/structured-mail/__tests__/booking-markup.test.ts b/lib/structured-mail/__tests__/booking-markup.test.ts new file mode 100644 index 000000000..0ed921acc --- /dev/null +++ b/lib/structured-mail/__tests__/booking-markup.test.ts @@ -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[]) => `${blocks.map((b) => ``).join('')}

Hello

`; + +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('Your Reservation is attached.', CTX)).toEqual({ cards: [], hadBookingMarkup: false }); + }); +}); diff --git a/lib/structured-mail/index.ts b/lib/structured-mail/index.ts index aead6b622..4b243333a 100644 --- a/lib/structured-mail/index.ts +++ b/lib/structured-mail/index.ts @@ -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'; diff --git a/lib/structured-mail/normalize.ts b/lib/structured-mail/normalize.ts index 252674e9c..e389f4b35 100644 --- a/lib/structured-mail/normalize.ts +++ b/lib/structured-mail/normalize.ts @@ -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) {