mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: read a booking from its markup alone when the mail has some, not from its Wallet pass
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user