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) {