diff --git a/src/components/email/structured-mail/__tests__/pkpass-source.test.ts b/src/components/email/structured-mail/__tests__/pkpass-source.test.ts new file mode 100644 index 00000000..10a9f01d --- /dev/null +++ b/src/components/email/structured-mail/__tests__/pkpass-source.test.ts @@ -0,0 +1,126 @@ +// Port of webmail components/email/structured-mail/__tests__/ +// pkpass-source.test.ts at 66c73d0f4, plus the app's download (the JMAP +// client mocked): the cap is enforced on the announced and the real size. +import { describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.hoisted(() => ({ respond: (_url: string): Response => new Response(null, { status: 404 }) })); +vi.mock('../../../../api/jmap-client', () => ({ + BLOB_TIMEOUT_MS: 300_000, + jmapClient: { + getBlobDownloadUrl: (blobId: string, name?: string, type?: string, accountId?: string) => `https://jmap.example/${accountId ?? 'primary'}/${blobId}/${name}?type=${type}`, + authenticatedFetch: vi.fn(async (url: string) => fetchMock.respond(url)), + }, +})); + +import type { Attachment, Email } from '../../../../api/types'; +import { jmapClient } from '../../../../api/jmap-client'; +import { MAIL_DATE, boardingPass, eventTicket, zipPass } from '../../../../lib/structured-mail/__tests__/pkpass-fixtures'; +import { fetchPassBytes, makePkpassSource, pkpassSource, type FetchPassBytes } from '../pkpass-source'; +import { CARD_SOURCES } from '../sources'; + +const PASS_ATT: Attachment = { blobId: 'blob-pass', size: 3000, name: 'BoardingPass.pkpass', type: 'application/octet-stream' }; +const PDF_ATT: Attachment = { blobId: 'blob-pdf', size: 3000, name: 'invoice.pdf', type: 'application/pdf' }; + +function mail(fields: Partial): Email { + return { id: 'm1', threadId: 't', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: true, receivedAt: MAIL_DATE.toISOString(), ...fields } as Email; +} + +const toBuffer = (bytes: Uint8Array) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + +function fetcherFor(blobs: Record) { + return vi.fn(async (blobId: string) => { + const blob = blobs[blobId]; + if (!blob) throw new Error(`no blob ${blobId}`); + if (blob instanceof Error) throw blob; + return toBuffer(blob); + }); +} + +function run(email: Email, fetchBytes: FetchPassBytes, signal = new AbortController().signal) { + return makePkpassSource(fetchBytes)({ email, mailDate: MAIL_DATE, accountId: 'acc-7', signal }); +} + +describe('pkpassSource', () => { + it('is registered as a card source', () => { + expect(CARD_SOURCES).toContain(pkpassSource); + }); + + it('downloads the pass attachment from the message account and returns its card', async () => { + const fetchBytes = fetcherFor({ 'blob-pass': await zipPass(boardingPass()) }); + const cards = await run(mail({ attachments: [PDF_ATT, PASS_ATT] }), fetchBytes); + expect(cards).toMatchObject([{ kind: 'flight', source: 'pkpass', reference: 'PK7Q2Z' }]); + expect(fetchBytes).toHaveBeenCalledTimes(1); + expect(fetchBytes).toHaveBeenCalledWith('blob-pass', 'BoardingPass.pkpass', 'application/octet-stream', 'acc-7', 5 * 1024 * 1024 + 1); + }); + + it('downloads nothing that is not a pass, and at most three passes', async () => { + const bytes = await zipPass(eventTicket()); + const passes = [1, 2, 3, 4].map((i) => ({ ...PASS_ATT, blobId: `p${i}` })); + const fetchBytes = fetcherFor({ p1: bytes, p2: bytes, p3: bytes, p4: bytes }); + await run(mail({ attachments: [PDF_ATT, { ...PASS_ATT, blobId: 'big', size: 6 * 1024 * 1024 }, ...passes] }), fetchBytes); + expect(fetchBytes.mock.calls.map((c) => c[0])).toEqual(['p1', 'p2', 'p3']); + }); + + it('reads email.attachments only, never a pass inside an attached message', async () => { + const fetchBytes = fetcherFor({ 'blob-pass': await zipPass(boardingPass()) }); + const embedded = mail({ + attachments: [{ blobId: 'blob-eml', size: 9000, name: 'forwarded.eml', type: 'message/rfc822' }], + bodyStructure: { + partId: '0', blobId: 'root', size: 9000, type: 'multipart/mixed', + subParts: [{ partId: '2', blobId: 'blob-eml', size: 9000, type: 'message/rfc822', subParts: [{ ...PASS_ATT }] }], + } as Email['bodyStructure'], + textBody: [{ ...PASS_ATT }] as Email['textBody'], + }); + expect(await run(embedded, fetchBytes)).toEqual([]); + expect(fetchBytes).not.toHaveBeenCalled(); + }); + + it('stops between downloads once the message is closed', async () => { + const bytes = await zipPass(boardingPass()); + const controller = new AbortController(); + const fetchBytes = fetcherFor({ p1: bytes, p2: bytes }); + fetchBytes.mockImplementationOnce(async () => { + controller.abort(); + return toBuffer(bytes); + }); + const cards = await run(mail({ attachments: [{ ...PASS_ATT, blobId: 'p1' }, { ...PASS_ATT, blobId: 'p2' }] }), fetchBytes, controller.signal); + expect(cards).toEqual([]); + expect(fetchBytes).toHaveBeenCalledTimes(1); + + const late = new AbortController(); + fetchBytes.mockImplementationOnce(async () => { + late.abort(); + return toBuffer(bytes); + }); + expect(await run(mail({ attachments: [{ ...PASS_ATT, blobId: 'p1' }] }), fetchBytes, late.signal)).toEqual([]); + }); + + it('gives an empty list, without throwing, when the download fails', async () => { + const fetchBytes = fetcherFor({ 'blob-pass': new Error('Failed to fetch blob: 500') }); + await expect(run(mail({ attachments: [PASS_ATT] }), fetchBytes)).resolves.toEqual([]); + expect(fetchBytes).toHaveBeenCalledTimes(1); + await expect(run(mail({ attachments: [{ ...PASS_ATT, blobId: '' }] }), fetchBytes)).resolves.toEqual([]); + expect(fetchBytes).toHaveBeenCalledTimes(1); + }); +}); + +describe('fetchPassBytes', () => { + it('downloads the blob from the message account with the client', async () => { + const bytes = await zipPass(boardingPass()); + fetchMock.respond = () => new Response(toBuffer(bytes), { status: 200, headers: { 'content-length': String(bytes.byteLength) } }); + const got = await fetchPassBytes('blob-pass', 'BoardingPass.pkpass', 'application/vnd.apple.pkpass', 'acc-7', 5 * 1024 * 1024 + 1); + expect(new Uint8Array(got)).toEqual(bytes); + expect(vi.mocked(jmapClient.authenticatedFetch)).toHaveBeenLastCalledWith( + 'https://jmap.example/acc-7/blob-pass/BoardingPass.pkpass?type=application/vnd.apple.pkpass', undefined, { timeoutMs: 300_000 }, + ); + }); + + it('refuses a blob the server says is larger than the cap, and one that turns out larger', async () => { + fetchMock.respond = () => new Response(new Uint8Array(10), { status: 200, headers: { 'content-length': '5000' } }); + await expect(fetchPassBytes('b', 'x.pkpass', 'application/vnd.apple.pkpass', undefined, 100)).rejects.toThrow(/larger/); + fetchMock.respond = () => new Response(new Uint8Array(200), { status: 200 }); + await expect(fetchPassBytes('b', 'x.pkpass', 'application/vnd.apple.pkpass', undefined, 100)).rejects.toThrow(/larger/); + fetchMock.respond = () => new Response(null, { status: 500 }); + await expect(fetchPassBytes('b', 'x.pkpass', 'application/vnd.apple.pkpass', undefined, 100)).rejects.toThrow(/500/); + }); +}); diff --git a/src/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.ts b/src/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.ts new file mode 100644 index 00000000..8119603f --- /dev/null +++ b/src/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.ts @@ -0,0 +1,163 @@ +// Port of webmail components/email/structured-mail/__tests__/ +// use-structured-mail-pkpass.test.tsx at 66c73d0f4. App: messages carry +// raw headers only, the gate is mocked, and the download goes through the +// JMAP client (mocked) from the account the reader passes. +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const gate = vi.hoisted(() => ({ on: true })); +vi.mock('../../../../lib/structured-mail/gate', () => ({ + isStructuredMailEnabled: () => gate.on, + useStructuredMailEnabled: () => gate.on, +})); +const blobs = vi.hoisted(() => ({ bytes: new Uint8Array() as Uint8Array, urls: [] as string[] })); +vi.mock('../../../../api/jmap-client', () => ({ + BLOB_TIMEOUT_MS: 300_000, + jmapClient: { + getBlobDownloadUrl: (blobId: string, name?: string, type?: string, accountId?: string) => `${accountId}|${blobId}|${name}|${type}`, + authenticatedFetch: async (url: string) => { + blobs.urls.push(url); + const b = blobs.bytes; + return new Response(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer, { status: 200 }); + }, + }, +})); + +import type { Email } from '../../../../api/types'; +import type { TransportBooking } from '../../../../lib/structured-mail'; +import { AIR_SEMANTICS, MAIL_DATE, TRAIN_SEMANTICS, boardingPass, zipPass } from '../../../../lib/structured-mail/__tests__/pkpass-fixtures'; +import { useStructuredMail } from '../use-structured-mail'; +import { renderHook, waitFor } from './render-native'; + +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 RECEIVED = 'from mail.swiss.example (mail.swiss.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1'; +const PLAIN = '

Your boarding pass is attached.

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

Your boarding pass is attached.

`; + +/** 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, hasAttachment: true, + receivedAt: MAIL_DATE.toISOString(), subject: 'Your boarding pass', + from: [{ email: 'boarding@swiss.example' }], + headers: [{ name: 'Received', value: RECEIVED }, ...(ar ? [{ name: 'Authentication-Results', value: ar }] : [])], + htmlBody: [{ partId: '1', type: html === null ? 'text/plain' : 'text/html' }], + bodyValues: htmlLoaded ? { '1': { value: html ?? 'Your boarding pass is attached.', isTruncated: false } } : {}, + attachments: [{ blobId: 'blob-pass', size: 3000, name: 'BoardingPass.pkpass', type: 'application/vnd.apple.pkpass' }], + }; +} + +async function passBlob(semantics: Record = AIR_SEMANTICS, transitType = 'PKTransitTypeAir') { + blobs.bytes = await zipPass(boardingPass(semantics, transitType)); +} + +/** Let effects and the airport-table import settle when nothing is expected to show. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + +afterEach(() => { + gate.on = true; + blobs.urls = []; +}); + +describe('useStructuredMail with a Wallet pass attached', () => { + it('shows the resolved flight card of a trusted mail without booking markup, and downloads nothing for an untrusted one', async () => { + await passBlob(); + const untrusted = renderHook(() => useStructuredMail(mail('u', { ar: null }), 'acc-1')); + const hook = renderHook(() => useStructuredMail(mail('t'), 'acc-1')); + await waitFor(() => expect(hook.current).toHaveLength(1)); + const [card] = hook.current as TransportBooking[]; + expect(card).toMatchObject({ kind: 'flight', reference: 'PK7Q2Z', source: 'pkpass' }); + expect(card.legs[0].departure.at).toMatchObject({ time: '07:40:00', zone: 'Europe/Zurich' }); + expect(card.legs[0].arrival?.at).toMatchObject({ time: '09:05:00', zone: 'Europe/Berlin' }); + expect(blobs.urls).toEqual(['acc-1|blob-pass|BoardingPass.pkpass|application/vnd.apple.pkpass']); + expect(untrusted.current).toEqual([]); + }); + + it('reads the pass of a plain-text mail too', async () => { + await passBlob(); + const hook = renderHook(() => useStructuredMail(mail('p', { html: null }))); + await waitFor(() => expect(hook.current).toMatchObject([{ kind: 'flight', source: 'pkpass' }])); + }); + + it('downloads nothing and shows nothing with the gate off', async () => { + gate.on = false; + await passBlob(); + const hook = renderHook(() => useStructuredMail(mail('g'))); + await settle(); + expect(hook.current).toEqual([]); + expect(blobs.urls).toEqual([]); + }); + + it('waits for the HTML before asking for passes, as it may hold booking markup', async () => { + await passBlob(); + const hook = renderHook(() => useStructuredMail(mail('w', { htmlLoaded: false }))); + await settle(); + expect(hook.current).toEqual([]); + expect(blobs.urls).toEqual([]); + }); + + describe('when the HTML has booking markup, the markup alone counts and nothing is downloaded', () => { + it('keeps a cancelled markup booking cancelled', async () => { + await passBlob(); + const hook = renderHook(() => useStructuredMail(mail('c', { html: ld(flightMarkup({ status: 'http://schema.org/ReservationCancelled' })) }))); + await waitFor(() => expect(hook.current).toHaveLength(1)); + await settle(); + expect(hook.current).toMatchObject([{ kind: 'flight', status: 'cancelled', source: 'json-ld' }]); + expect(blobs.urls).toEqual([]); + }); + + it('shows nothing when the markup booking was rejected', async () => { + await passBlob(); + const html = ld(flightMarkup(), flightMarkup({ departureTime: '2026-12-03T08:10:00+01:00' })); + const hook = renderHook(() => useStructuredMail(mail('r', { html }))); + await settle(); + expect(hook.current).toEqual([]); + expect(blobs.urls).toEqual([]); + }); + + it('shows one train card with the markup leg when the pass writes number and station differently', async () => { + await passBlob(TRAIN_SEMANTICS, 'PKTransitTypeTrain'); + const hook = renderHook(() => useStructuredMail(mail('i', { html: ld(TRAIN_MARKUP) }))); + await waitFor(() => expect(hook.current).toHaveLength(1)); + await settle(); + const cards = hook.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(blobs.urls).toEqual([]); + }); + + it('shows one flight card when the pass has no confirmation number', async () => { + const { confirmationNumber: _ref, ...noRef } = AIR_SEMANTICS; + await passBlob(noRef); + const hook = renderHook(() => useStructuredMail(mail('f', { html: ld(flightMarkup()) }))); + await waitFor(() => expect(hook.current).toHaveLength(1)); + await settle(); + expect(hook.current).toMatchObject([{ kind: 'flight', source: 'json-ld', reference: 'PK7Q2Z' }]); + expect(blobs.urls).toEqual([]); + }); + }); +}); diff --git a/src/components/email/structured-mail/pkpass-source.ts b/src/components/email/structured-mail/pkpass-source.ts new file mode 100644 index 00000000..3a67c5a9 --- /dev/null +++ b/src/components/email/structured-mail/pkpass-source.ts @@ -0,0 +1,61 @@ +// Mirrors webmail components/email/structured-mail/pkpass-source.ts at +// 66c73d0f4; keep the two in step. App: the webmail's client reads the +// blob as a stream and stops one byte past the cap; React Native's fetch +// can't stream, so the download is refused when the server announces more +// than the cap and the bytes are counted once they are in. +import { PKPASS_MAX_BYTES, PKPASS_MAX_PER_MAIL, isPkpassAttachment, readPkpass } from '../../../lib/structured-mail/pkpass'; +import type { StructuredCard } from '../../../lib/structured-mail'; +import type { CardSource } from './sources'; + +/** Download a blob of at most `maxBytes` from the message's JMAP account. */ +export type FetchPassBytes = (blobId: string, name: string | undefined, type: string, accountId: string | undefined, maxBytes: number) => Promise; + +/** The app's download: the JMAP client's authenticated blob fetch, capped. */ +export const fetchPassBytes: FetchPassBytes = async (blobId, name, type, accountId, maxBytes) => { + // Lazy: the client pulls in native modules the pure parts and their tests must not load. + const { jmapClient, BLOB_TIMEOUT_MS } = await import('../../../api/jmap-client'); + const url = jmapClient.getBlobDownloadUrl(blobId, name, type, accountId); + const response = await jmapClient.authenticatedFetch(url, undefined, { timeoutMs: BLOB_TIMEOUT_MS }); + if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`); + const announced = Number(response.headers.get('content-length')); + if (Number.isFinite(announced) && announced > maxBytes) throw new Error('blob larger than its listed size'); + const bytes = await response.arrayBuffer(); + if (bytes.byteLength > maxBytes) throw new Error('blob larger than its listed size'); + return bytes; +}; + +/** + * Cards from Apple Wallet passes attached to a trusted message. Reads only + * `email.attachments`, the message's own parts: never the parts the viewer + * unwraps from an attached .eml or a TNEF winmail.dat, which the sender's + * authentication says nothing about. At most three passes, each downloaded + * from the message's own JMAP account and capped at 5 MB; stops between + * downloads once the message is closed. Never throws. + */ +export function makePkpassSource(fetchBytes: FetchPassBytes): CardSource { + return async ({ email, mailDate, accountId, signal }) => { + try { + if (signal.aborted) return []; + const attachments = (Array.isArray(email.attachments) ? email.attachments : []) + .filter((att) => !!att && typeof att.blobId === 'string' && att.blobId !== '' && isPkpassAttachment(att)) + .slice(0, PKPASS_MAX_PER_MAIL); + const cards: StructuredCard[] = []; + for (const att of attachments) { + if (signal.aborted) return []; + let bytes: ArrayBuffer; + try { + // One byte past the cap, so a blob larger than its listed size is noticed and refused. + bytes = await fetchBytes(att.blobId, att.name, att.type, accountId, PKPASS_MAX_BYTES + 1); + } catch { + continue; + } + cards.push(...await readPkpass(bytes, { mailDate })); + } + return signal.aborted ? [] : cards; + } catch { + return []; + } + }; +} + +export const pkpassSource: CardSource = makePkpassSource(fetchPassBytes); diff --git a/src/components/email/structured-mail/sources.ts b/src/components/email/structured-mail/sources.ts index ff45465d..5bee216b 100644 --- a/src/components/email/structured-mail/sources.ts +++ b/src/components/email/structured-mail/sources.ts @@ -3,6 +3,7 @@ // since the app has one client (api/jmap-client). import type { Email } from '../../../api/types'; import type { StructuredCard } from '../../../lib/structured-mail'; +import { pkpassSource } from './pkpass-source'; /** * Card sources beyond the HTML markup, read asynchronously once a trusted @@ -22,5 +23,5 @@ export interface CardSourceContext { export type CardSource = (context: CardSourceContext) => Promise; -/** Registered sources. */ -export const CARD_SOURCES: CardSource[] = []; +/** Registered sources: Apple Wallet passes. */ +export const CARD_SOURCES: CardSource[] = [pkpassSource]; diff --git a/src/lib/structured-mail/__tests__/pkpass-fixtures.ts b/src/lib/structured-mail/__tests__/pkpass-fixtures.ts new file mode 100644 index 00000000..5630b29f --- /dev/null +++ b/src/lib/structured-mail/__tests__/pkpass-fixtures.ts @@ -0,0 +1,96 @@ +// Mirrors webmail lib/structured-mail/__tests__/pkpass-fixtures.ts at 63830d403; keep the two in step. +/** + * Self-written Wallet passes for the .pkpass tests (made-up values, not + * Apple's or Google's documentation examples), and a jszip builder that + * packs them the way a real .pkpass is packed. + */ +import JSZip from 'jszip'; + +export const MAIL_DATE = new Date('2026-11-20T08:00:00Z'); + +type Json = Record; + +function pass(style: string, styleBody: Json, semantics: Json | null, extra: Json = {}): Json { + return { + formatVersion: 1, + passTypeIdentifier: 'pass.org.example.boarding', + serialNumber: 'SER-55120', + teamIdentifier: 'ABCDE12345', + organizationName: 'Swiss', + description: 'Boarding pass', + relevantDate: '2026-12-03T07:05+01:00', + webServiceURL: 'https://passes.example.org/v1', + authenticationToken: 'never-used-token-0000', + barcodes: [{ format: 'PKBarcodeFormatQR', message: 'M1BEISPIEL/JANA', messageEncoding: 'iso-8859-1' }], + [style]: { primaryFields: [{ key: 'origin', label: 'ZRH', value: 'Zurich' }], ...styleBody }, + ...(semantics ? { semantics } : {}), + ...extra, + }; +} + +export const AIR_SEMANTICS: Json = { + airlineCode: 'LX', + flightNumber: 1072, + flightCode: 'LX1072', + confirmationNumber: 'PK7Q2Z', + departureAirportCode: 'ZRH', + departureAirportName: 'Zurich Airport', + destinationAirportCode: 'BER', + destinationAirportName: 'Berlin Brandenburg', + originalDepartureDate: '2026-12-03T07:40+01:00', + originalArrivalDate: '2026-12-03T09:05+01:00', + departureLocationTimeZone: 'Europe/Zurich', + destinationLocationTimeZone: 'Europe/Berlin', + passengerName: { givenName: 'Jana', familyName: 'Beispiel' }, + seats: [{ seatRow: '14', seatNumber: 'C' }], + transitProvider: 'Swiss International Air Lines', +}; + +export const boardingPass = (semantics: Json | null = AIR_SEMANTICS, transitType = 'PKTransitTypeAir', extra: Json = {}) => + pass('boardingPass', { transitType }, semantics, extra); + +export const TRAIN_SEMANTICS: Json = { + confirmationNumber: 'ZUG4471', + transitProvider: 'Deutsche Bahn', + vehicleNumber: 'ICE 597', + departureStationName: 'Hamburg Hbf', + destinationStationName: 'Köln Hbf', + originalDepartureDate: '2026-12-05T08:46:00+01:00', + originalArrivalDate: '2026-12-05T12:50:00+01:00', + carNumber: '7', + seats: [{ seatNumber: '45' }], + passengerName: { givenName: 'Jana', familyName: 'Beispiel' }, +}; + +export const BUS_SEMANTICS: Json = { + confirmationNumber: 'BUS-80411', + transitProvider: 'Fernbus Nord', + departureStationName: 'Bremen ZOB', + destinationStationName: 'Hannover ZOB', + currentDepartureDate: '2026-12-06T10:15:00+01:00', + passengerName: { givenName: 'Ole', familyName: 'Probe' }, +}; + +export const EVENT_SEMANTICS: Json = { + eventName: 'Kammerkonzert im Turmsaal', + eventStartDate: '2026-12-12T19:30:00+01:00', + eventEndDate: '2026-12-12T21:45:00+01:00', + venueName: 'Turmsaal Hildesheim', + venueRegionName: 'Hildesheim', + confirmationNumber: 'TKT-30918', + attendeeName: 'Jana Beispiel', +}; + +export const eventTicket = (semantics: Json | null = EVENT_SEMANTICS) => pass('eventTicket', {}, semantics); +export const otherPass = (style: 'storeCard' | 'coupon' | 'generic') => pass(style, {}, { ...EVENT_SEMANTICS, ...AIR_SEMANTICS }); + +/** A .pkpass archive: pass.json plus the files a real one carries. */ +export async function zipPass(passJson: unknown, options: { raw?: string; files?: Record } = {}): Promise { + const zip = new JSZip(); + zip.file('pass.json', options.raw ?? JSON.stringify(passJson)); + zip.file('manifest.json', JSON.stringify({ 'pass.json': '0000000000000000000000000000000000000000' })); + zip.file('signature', 'not a real signature'); + zip.file('icon.png', 'png bytes'); + for (const [name, content] of Object.entries(options.files ?? {})) zip.file(name, content); + return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); +} diff --git a/src/lib/structured-mail/__tests__/pkpass.test.ts b/src/lib/structured-mail/__tests__/pkpass.test.ts new file mode 100644 index 00000000..dbf37d04 --- /dev/null +++ b/src/lib/structured-mail/__tests__/pkpass.test.ts @@ -0,0 +1,293 @@ +// Mirrors webmail lib/structured-mail/__tests__/pkpass.test.ts at 1d4cae961; keep the two in step. +import JSZip from 'jszip'; +import { describe, expect, it } from 'vitest'; +import { loadAirportZones, mergeCandidates, readStructuredMail, resolveCards, type EventBooking, type TransportBooking } from '..'; +import { isPkpassAttachment, passToCards, readPassJson, readPkpass } from '../pkpass'; +import { + AIR_SEMANTICS, + BUS_SEMANTICS, + EVENT_SEMANTICS, + MAIL_DATE, + TRAIN_SEMANTICS, + boardingPass, + eventTicket, + otherPass, + zipPass, +} from './pkpass-fixtures'; + +const CTX = { mailDate: MAIL_DATE }; + +/** The same flight as AIR_SEMANTICS, as the booking mail's JSON-LD would give it. */ +const MARKUP = ``; + +async function cardsFrom(pass: unknown) { + return readPkpass(await zipPass(pass), CTX); +} + +describe('isPkpassAttachment', () => { + it('goes by file name or type, up to 5 MB', () => { + expect(isPkpassAttachment({ name: 'BoardingPass.PKPASS', type: 'application/octet-stream', size: 4000 })).toBe(true); + expect(isPkpassAttachment({ name: 'ticket', type: 'application/vnd.apple.pkpass; name=x', size: 4000 })).toBe(true); + expect(isPkpassAttachment({ name: 'ticket.pdf', type: 'application/pdf', size: 4000 })).toBe(false); + expect(isPkpassAttachment({ name: 'big.pkpass', type: 'application/vnd.apple.pkpass', size: 5 * 1024 * 1024 + 1 })).toBe(false); + expect(isPkpassAttachment({ name: 'empty.pkpass', type: 'application/vnd.apple.pkpass', size: 0 })).toBe(false); + }); +}); + +describe('passToCards', () => { + it('turns an air boarding pass into the flight booking the markup describes, with the same key', async () => { + const [card] = await cardsFrom(boardingPass()) as TransportBooking[]; + expect(card).toMatchObject({ + kind: 'flight', source: 'pkpass', reference: 'PK7Q2Z', passengers: ['Jana Beispiel'], + provider: 'Swiss International Air Lines', links: {}, + }); + expect(card.legs).toEqual([{ + carrier: 'Swiss International Air Lines', carrierCode: 'LX', number: '1072', + departure: { place: { code: 'ZRH', name: 'Zurich Airport' }, at: { date: '2026-12-03', time: '07:40:00', offsetMinutes: 60, utc: '2026-12-03T06:40:00Z' } }, + arrival: { place: { code: 'BER', name: 'Berlin Brandenburg' }, at: { date: '2026-12-03', time: '09:05:00', offsetMinutes: 60, utc: '2026-12-03T08:05:00Z' } }, + seats: [{ passenger: 'Jana Beispiel', seat: '14C' }], + }]); + const [markup] = readStructuredMail(MARKUP, CTX); + expect(markup.key).toBe('flight|ref:pk7q2z'); + expect(card.key).toBe(markup.key); + }); + + it('merges with the markup booking into one card with one leg', async () => { + const pass = await cardsFrom(boardingPass()); + const markup = readStructuredMail(MARKUP, CTX); + const merged = mergeCandidates([...markup, ...pass].map((card) => ({ card, problems: [] }))) as TransportBooking[]; + expect(merged).toHaveLength(1); + expect(merged[0].legs).toHaveLength(1); + expect(merged[0].legs[0].seats).toEqual([{ passenger: 'Jana Beispiel', seat: '14C' }]); + expect(merged[0].passengers).toEqual(['Jana Beispiel']); + }); + + it('merges with markup that pads the flight number with zeros (LX0072 and flightNumber 72)', async () => { + const pass = await cardsFrom(boardingPass({ ...AIR_SEMANTICS, flightNumber: 72, flightCode: 'LX72' })); + const markup = readStructuredMail(MARKUP.replace('"flightNumber":"LX1072"', '"flightNumber":"LX0072"'), CTX); + const merged = mergeCandidates([...markup, ...pass].map((card) => ({ card, problems: [] }))) as TransportBooking[]; + expect(merged).toHaveLength(1); + expect(merged[0].legs).toHaveLength(1); + const { confirmationNumber: _ref, ...noRef } = AIR_SEMANTICS; + const [unreferenced] = await cardsFrom(boardingPass({ ...noRef, flightNumber: 72, flightCode: 'LX72' })); + const [markupLeg] = readStructuredMail(MARKUP.replace('"reservationNumber":"PK7Q2Z",', '').replace('"flightNumber":"LX1072"', '"flightNumber":"LX0072"'), CTX); + expect(unreferenced.key).toBe(markupLeg.key); + }); + + it('keys a pass without confirmation number by its leg, like markup without one', async () => { + const { confirmationNumber: _ref, ...semantics } = AIR_SEMANTICS; + const [card] = await cardsFrom(boardingPass(semantics)); + expect(card.key).toBe('flight|leg:lx|1072|2026-12-03|zrh'); + const [markup] = readStructuredMail(MARKUP.replace('"reservationNumber":"PK7Q2Z",', ''), CTX); + expect(card.key).toBe(markup.key); + }); + + it('gives nothing for a flight whose departure airport has a name but no code, as its leg could not be matched', async () => { + const { departureAirportCode: _c, ...nameOnly } = AIR_SEMANTICS; + expect(await cardsFrom(boardingPass(nameOnly))).toEqual([]); + expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, departureAirportCode: 'LSZH' }))).toEqual([]); + }); + + it('keeps a leg without arrival time and resolves its zone like any flight', async () => { + const { originalArrivalDate: _a, ...semantics } = AIR_SEMANTICS; + const cards = await cardsFrom(boardingPass(semantics)); + const [card] = resolveCards(cards, await loadAirportZones()) as TransportBooking[]; + expect(card.legs[0].arrival).toEqual({ place: { code: 'BER', name: 'Berlin Brandenburg' } }); + expect(card.legs[0].departure.at.zone).toBe('Europe/Zurich'); + }); + + it('reads flightCode alone and refuses a flightCode that contradicts airlineCode and flightNumber', async () => { + const { airlineCode: _c, flightNumber: _n, ...codeOnly } = AIR_SEMANTICS; + const [card] = await cardsFrom(boardingPass(codeOnly)) as TransportBooking[]; + expect(card.legs[0]).toMatchObject({ carrierCode: 'LX', number: '1072' }); + expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, flightCode: 'LX1073' }))).toEqual([]); + expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, airlineCode: 'SWR' }))).toEqual([]); + }); + + it('uses the updated departure, and only the updated arrival with it', async () => { + const [card] = await cardsFrom(boardingPass({ ...AIR_SEMANTICS, currentDepartureDate: '2026-12-03T08:25+01:00' })) as TransportBooking[]; + expect(card.legs[0].departure.at.time).toBe('08:25:00'); + expect(card.legs[0].arrival?.at).toBeUndefined(); + expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, currentDepartureDate: '2026-12-04T00:30+01:00' }))).toEqual([]); + }); + + it('pairs the scheduled arrival only with the scheduled departure', async () => { + const { originalDepartureDate: _d, ...noOriginal } = AIR_SEMANTICS; + const [delayed] = await cardsFrom(boardingPass({ ...noOriginal, currentDepartureDate: '2026-12-03T08:25+01:00' })) as TransportBooking[]; + expect(delayed.legs[0].departure.at.time).toBe('08:25:00'); + expect(delayed.legs[0].arrival).toEqual({ place: { code: 'BER', name: 'Berlin Brandenburg' } }); + const [updated] = await cardsFrom(boardingPass({ ...noOriginal, currentDepartureDate: '2026-12-03T08:25+01:00', currentArrivalDate: '2026-12-03T09:50+01:00' })) as TransportBooking[]; + expect(updated.legs[0].arrival?.at?.time).toBe('09:50:00'); + const [same] = await cardsFrom(boardingPass({ ...AIR_SEMANTICS, currentDepartureDate: '2026-12-03T07:40:00+01:00' })) as TransportBooking[]; + expect(same.legs[0].arrival?.at?.time).toBe('09:05:00'); + }); + + it('places a UTC flight time in the airport zone once resolved, with or without a zone tag', async () => { + const zones = await loadAirportZones(); + const { departureLocationTimeZone: _z, destinationLocationTimeZone: _y, ...noTags } = AIR_SEMANTICS; + const utc = { ...noTags, originalDepartureDate: '2026-12-03T06:40:00Z', originalArrivalDate: '2026-12-03T08:05:00Z' }; + const raw = await cardsFrom(boardingPass(utc)) as TransportBooking[]; + expect(raw[0].legs[0].departure.at.instantOnly).toBe(true); + const [card] = resolveCards(raw, zones) as TransportBooking[]; + expect(card.legs[0].departure.at).toEqual({ date: '2026-12-03', time: '07:40:00', offsetMinutes: 60, utc: '2026-12-03T06:40:00Z', zone: 'Europe/Zurich' }); + expect(card.legs[0].arrival?.at).toMatchObject({ time: '09:05:00', zone: 'Europe/Berlin' }); + expect(resolveCards(raw)).toEqual([]); + + const [tagged] = await cardsFrom(boardingPass({ ...AIR_SEMANTICS, originalDepartureDate: '2026-12-03T06:40:00+00:00' })) as TransportBooking[]; + expect(tagged.legs[0].departure.at).toEqual({ date: '2026-12-03', time: '07:40:00', offsetMinutes: 60, utc: '2026-12-03T06:40:00Z' }); + + const london = { ...noTags, airlineCode: 'BA', flightNumber: 710, flightCode: 'BA710', departureAirportCode: 'LHR', departureAirportName: 'Heathrow', originalDepartureDate: '2026-12-03T06:40:00+00:00', originalArrivalDate: '2026-12-03T09:10:00+01:00' }; + const [lhr] = resolveCards(await cardsFrom(boardingPass(london)), zones) as TransportBooking[]; + expect(lhr.legs[0].departure.at).toMatchObject({ date: '2026-12-03', time: '06:40:00', offsetMinutes: 0, zone: 'Europe/London' }); + }); + + it('places a UTC train time only with a zone tag, and never a UTC event time', async () => { + const utcTrain = { ...TRAIN_SEMANTICS, originalDepartureDate: '2026-12-05T07:46:00Z', originalArrivalDate: '2026-12-05T11:50:00Z' }; + expect(await cardsFrom(boardingPass(utcTrain, 'PKTransitTypeTrain'))).toEqual([]); + const tagged = { ...utcTrain, departureLocationTimeZone: 'Europe/Berlin', destinationLocationTimeZone: 'Europe/Berlin' }; + const [train] = await cardsFrom(boardingPass(tagged, 'PKTransitTypeTrain')) as TransportBooking[]; + expect(train.legs[0].departure.at).toEqual({ date: '2026-12-05', time: '08:46:00', offsetMinutes: 60, utc: '2026-12-05T07:46:00Z' }); + expect(train.legs[0].arrival?.at?.time).toBe('12:50:00'); + expect(await cardsFrom(boardingPass({ ...BUS_SEMANTICS, currentDepartureDate: '2026-12-06T09:15:00Z' }, 'PKTransitTypeBus'))).toEqual([]); + expect(await cardsFrom(eventTicket({ ...EVENT_SEMANTICS, eventStartDate: '2026-12-12T18:30:00Z' }))).toEqual([]); + expect(await cardsFrom(eventTicket({ ...EVENT_SEMANTICS, eventEndDate: '2026-12-12T20:45:00Z' }))).toEqual([]); + }); + + it('keeps the card only for a transit status that says the trip runs as planned', async () => { + for (const status of ['On Time', 'pünktlich', 'Boarding', 'SCHEDULED', 'planmäßig', 'on-time']) { + expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, transitStatus: status })), status).toHaveLength(1); + } + for (const status of ['Delayed', 'Verspätet 40 Min.', 'Diverted', 'fällt aus', 'gestrichen', 'Gate closed', 'Cancelled']) { + expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, transitStatus: status })), status).toEqual([]); + } + }); + + it('gives nothing when the pass zone disagrees with the time, or the status says cancelled', async () => { + expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, departureLocationTimeZone: 'America/New_York' }))).toEqual([]); + expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, departureLocationTimeZone: 'Not/AZone' }))).toEqual([]); + expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, transitStatus: 'Cancelled' }))).toEqual([]); + }); + + it('turns a train boarding pass into a train booking', async () => { + const [card] = await cardsFrom(boardingPass(TRAIN_SEMANTICS, 'PKTransitTypeTrain')) as TransportBooking[]; + expect(card).toMatchObject({ kind: 'train', reference: 'ZUG4471', key: 'train|ref:zug4471', provider: 'Deutsche Bahn', passengers: ['Jana Beispiel'] }); + expect(card.legs).toEqual([{ + carrier: 'Deutsche Bahn', number: 'ICE 597', + departure: { place: { name: 'Hamburg Hbf' }, at: { date: '2026-12-05', time: '08:46:00', offsetMinutes: 60, utc: '2026-12-05T07:46:00Z' } }, + arrival: { place: { name: 'Köln Hbf' }, at: { date: '2026-12-05', time: '12:50:00', offsetMinutes: 60, utc: '2026-12-05T11:50:00Z' } }, + seats: [{ passenger: 'Jana Beispiel', seat: '7/45' }], + }]); + }); + + it('turns a bus boarding pass into a bus booking', async () => { + const [card] = await cardsFrom(boardingPass(BUS_SEMANTICS, 'PKTransitTypeBus')) as TransportBooking[]; + expect(card).toMatchObject({ kind: 'bus', reference: 'BUS-80411', passengers: ['Ole Probe'] }); + expect(card.legs[0]).toMatchObject({ departure: { place: { name: 'Bremen ZOB' }, at: { time: '10:15:00' } }, arrival: { place: { name: 'Hannover ZOB' } } }); + }); + + it('turns an event ticket into an event booking', async () => { + const [card] = await cardsFrom(eventTicket()) as EventBooking[]; + expect(card).toEqual({ + key: 'event|tkt-30918|kammerkonzert im turmsaal|2026-12-12', kind: 'event', reference: 'TKT-30918', + passengers: ['Jana Beispiel'], provider: 'Swiss', links: {}, source: 'pkpass', name: 'Kammerkonzert im Turmsaal', + venue: { name: 'Turmsaal Hildesheim', address: { locality: 'Hildesheim' } }, + start: { date: '2026-12-12', time: '19:30:00', offsetMinutes: 60, utc: '2026-12-12T18:30:00Z' }, + end: { date: '2026-12-12', time: '21:45:00', offsetMinutes: 60, utc: '2026-12-12T20:45:00Z' }, + tickets: [], + }); + }); + + it('ignores store cards, coupons and generic passes', async () => { + for (const style of ['storeCard', 'coupon', 'generic'] as const) expect(await cardsFrom(otherPass(style))).toEqual([]); + }); + + it('gives nothing for a pass without semantics, even with relevantDate and organizationName', async () => { + expect(await cardsFrom(boardingPass(null))).toEqual([]); + expect(await cardsFrom(eventTicket(null))).toEqual([]); + }); + + it('gives nothing for boat, generic or unknown transit types', async () => { + for (const type of ['PKTransitTypeBoat', 'PKTransitTypeGeneric', 'PKTransitTypeRocket', '']) { + expect(await cardsFrom(boardingPass(AIR_SEMANTICS, type))).toEqual([]); + } + }); + + it('gives nothing when semantics lack what validation needs', async () => { + const { originalDepartureDate: _d, ...noDeparture } = AIR_SEMANTICS; + expect(await cardsFrom(boardingPass(noDeparture))).toEqual([]); + const { destinationAirportCode: _c, destinationAirportName: _n, ...noArrival } = AIR_SEMANTICS; + expect(await cardsFrom(boardingPass(noArrival))).toEqual([]); + const { departureStationName: _s, ...noStation } = TRAIN_SEMANTICS; + expect(await cardsFrom(boardingPass(noStation, 'PKTransitTypeTrain'))).toEqual([]); + const { eventStartDate: _e, ...noStart } = EVENT_SEMANTICS; + expect(await cardsFrom(eventTicket(noStart))).toEqual([]); + expect(await cardsFrom(boardingPass({ ...AIR_SEMANTICS, originalDepartureDate: '3 Dec 2026 07:40' }))).toEqual([]); + }); + + it('gives nothing for a voided pass, a wrong formatVersion or two pass styles', async () => { + expect(await cardsFrom(boardingPass(AIR_SEMANTICS, 'PKTransitTypeAir', { voided: true }))).toEqual([]); + expect(await cardsFrom(boardingPass(AIR_SEMANTICS, 'PKTransitTypeAir', { formatVersion: 2 }))).toEqual([]); + expect(await cardsFrom(boardingPass(AIR_SEMANTICS, 'PKTransitTypeAir', { eventTicket: {} }))).toEqual([]); + }); + + it('gives nothing for dates far from the mail date', async () => { + const far = { ...AIR_SEMANTICS, originalDepartureDate: '2028-06-03T07:40+02:00', originalArrivalDate: '2028-06-03T09:05+02:00' }; + expect(passToCards(boardingPass(far), CTX)).toEqual([]); + expect(passToCards(eventTicket({ ...EVENT_SEMANTICS, eventStartDate: '2024-01-12T19:30:00+01:00', eventEndDate: undefined }), CTX)).toEqual([]); + }); +}); + +describe('readPassJson', () => { + it('reads pass.json, with or without a byte order mark', async () => { + expect(await readPassJson(await zipPass(boardingPass()))).toMatchObject({ formatVersion: 1, semantics: { airlineCode: 'LX' } }); + expect(await readPassJson(await zipPass(null, { raw: String.fromCharCode(0xfeff) + JSON.stringify(eventTicket()) }))).toMatchObject({ eventTicket: {} }); + }); + + it('gives undefined without throwing for anything doubtful', async () => { + const valid = await zipPass(boardingPass()); + const corrupt = valid.slice(0, valid.length / 2); + const noPass = await (() => { const zip = new JSZip(); zip.file('manifest.json', '{}'); return zip.generateAsync({ type: 'uint8array' }); })(); + const huge = await zipPass({ ...boardingPass(), padding: 'x'.repeat(300 * 1024) }); + const manyFiles = await zipPass(boardingPass(), { files: Object.fromEntries(Array.from({ length: 100 }, (_, i) => [`en.lproj/f${i}.txt`, 'x'])) }); + const notJson = await zipPass(null, { raw: '{"formatVersion": 1, "boardingPass": ' }); + const notObject = await zipPass(null, { raw: '[1, 2]' }); + for (const bytes of [new Uint8Array(), new Uint8Array([1, 2, 3, 4]), corrupt, noPass, huge, manyFiles, notJson, notObject]) { + await expect(readPassJson(bytes)).resolves.toBeUndefined(); + } + expect(await readPkpass(huge, CTX)).toEqual([]); + }); + + it('never inflates anything but pass.json', async () => { + const zip = new JSZip(); + zip.file('pass.json', JSON.stringify(boardingPass())); + zip.file('strip.png', 'y'.repeat(4 * 1024 * 1024)); + zip.file('en.lproj/pass.strings', '"a" = "b";'); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + const real = await JSZip.loadAsync(bytes); + // Every way to inflate an entry, on every entry, however it is reached + // (zip.file(), zip.files[...], forEach): record which entries are read. + const inflated: string[] = []; + for (const entry of Object.values(real.files)) { + const target = entry as unknown as Record unknown>; + for (const method of ['async', 'internalStream', 'nodeStream']) { + const original = target[method]; + if (typeof original !== 'function') continue; + target[method] = function (this: unknown, ...args: unknown[]) { + inflated.push(entry.name); + return original.apply(this, args); + }; + } + } + const loadZip = async () => ({ loadAsync: async () => real }) as unknown as typeof JSZip; + expect(await readPkpass(bytes, CTX, { loadZip })).toHaveLength(1); + expect(inflated).toEqual(['pass.json']); + }); +}); diff --git a/src/lib/structured-mail/__tests__/utf8.test.ts b/src/lib/structured-mail/__tests__/utf8.test.ts new file mode 100644 index 00000000..6f0f3e15 --- /dev/null +++ b/src/lib/structured-mail/__tests__/utf8.test.ts @@ -0,0 +1,48 @@ +// utf8.ts: pass.json decoding where Hermes lacks a fatal TextDecoder. +import { afterEach, describe, expect, it } from 'vitest'; +import { readPassJson } from '../pkpass'; +import { decodeByHand, decodeUtf8Strict } from '../utf8'; +import { boardingPass, zipPass } from './pkpass-fixtures'; + +const encode = (text: string) => new TextEncoder().encode(text); +const original = globalThis.TextDecoder; + +afterEach(() => { + globalThis.TextDecoder = original; +}); + +describe('decodeByHand', () => { + it('decodes what TextEncoder wrote, from ASCII to astral characters', () => { + const text = 'Zürich → 東京 \u{1F6EB} ok'; + expect(decodeByHand(encode(text))).toBe(text); + const long = 'é'.repeat(10_000); + expect(decodeByHand(encode(long))).toBe(long); + }); + + it('throws on malformed input as a fatal decoder does', () => { + for (const bad of [[0xc3], [0xff], [0xc0, 0x80], [0xed, 0xa0, 0x80], [0xf4, 0x90, 0x80, 0x80], [0xe2, 0x28, 0xa1]]) { + expect(() => decodeByHand(new Uint8Array(bad))).toThrow(); + expect(() => new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bad))).toThrow(); + } + }); +}); + +describe('decodeUtf8Strict without TextDecoder', () => { + it('still reads a Wallet pass', async () => { + const bytes = await zipPass(boardingPass()); + (globalThis as { TextDecoder?: unknown }).TextDecoder = undefined; + expect(decodeUtf8Strict(encode('Köln'))).toBe('Köln'); + expect(await readPassJson(bytes)).toMatchObject({ formatVersion: 1 }); + }); + + it('falls back when the decoder refuses the fatal option', () => { + globalThis.TextDecoder = class { + constructor(_label?: string, options?: { fatal?: boolean }) { + if (options?.fatal) throw new RangeError('fatal is not supported'); + } + decode(): string { return 'wrong'; } + } as unknown as typeof TextDecoder; + expect(decodeUtf8Strict(encode('Köln'))).toBe('Köln'); + expect(() => decodeUtf8Strict(new Uint8Array([0xc3]))).toThrow(); + }); +}); diff --git a/src/lib/structured-mail/pkpass.ts b/src/lib/structured-mail/pkpass.ts new file mode 100644 index 00000000..3797418a --- /dev/null +++ b/src/lib/structured-mail/pkpass.ts @@ -0,0 +1,422 @@ +// Mirrors webmail lib/structured-mail/pkpass.ts at 1d4cae961; keep the two in step. +/** + * Apple Wallet passes (.pkpass) attached to booking mail: the same cards as + * schema.org markup would give. A boarding pass becomes a flight, train or + * bus booking and an event ticket an event booking; every other pass is + * ignored. Only the pass's `semantics` dictionary counts, as that is the + * machine-readable part (Apple's "SemanticTags"); the display fields are + * free text for people. A pass that leaves anything in doubt gives no card. + * + * The signature is not checked (that needs Apple's certificate chain); the + * mail-level trust gate stands in for it. Nothing in the pass is fetched: + * webServiceURL, images and links are never read. + * + * Plain TypeScript, no React; jszip is imported only when a pass is opened. + */ +import type JSZip from 'jszip'; +import { cardKey } from './normalize'; +import { isValidTimeZone, wallClockAt, zoneOffsetAt } from './time-zones'; +import type { EventBooking, Moment, Place, Seat, StructuredCard, TransportBooking, TransportKind, TransportLeg } from './types'; +import { validateCard } from './validate'; +import { decodeUtf8Strict } from './utf8'; +import { isObj, moment, str, type Obj } from './values'; + +/** Largest attachment that is downloaded and opened. */ +export const PKPASS_MAX_BYTES = 5 * 1024 * 1024; +/** At most this many passes are read from one mail. */ +export const PKPASS_MAX_PER_MAIL = 3; +/** Largest pass.json read, once inflated. */ +export const PASS_JSON_MAX_BYTES = 256 * 1024; +/** Archives with more entries are refused unread. */ +export const PKPASS_MAX_ENTRIES = 100; + +const PKPASS_TYPE = 'application/vnd.apple.pkpass'; + +export interface PkpassAttachmentLike { + name?: string | null; + type?: string | null; + size?: number | null; +} + +/** An attachment that may be a Wallet pass: `*.pkpass` or typed as one, at most 5 MB. */ +export function isPkpassAttachment(att: PkpassAttachmentLike | null | undefined): boolean { + if (!att) return false; + const size = att.size; + if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0 || size > PKPASS_MAX_BYTES) return false; + const byName = typeof att.name === 'string' && /\.pkpass$/i.test(att.name.trim()); + const byType = typeof att.type === 'string' && att.type.split(';')[0].trim().toLowerCase() === PKPASS_TYPE; + return byName || byType; +} + +export interface ReadPassOptions { + /** jszip's constructor; defaults to a dynamic import of jszip. */ + loadZip?: () => Promise; +} + +async function defaultLoadZip(): Promise { + const { default: JSZipClass } = await import('jszip'); + return JSZipClass; +} + +interface ZipStream { + on(event: 'data', cb: (chunk: Uint8Array) => void): ZipStream; + on(event: 'error', cb: (err: Error) => void): ZipStream; + on(event: 'end', cb: () => void): ZipStream; + pause(): ZipStream; + resume(): ZipStream; +} + +/** + * Inflate one entry, giving up as soon as it passes `maxBytes`. The sizes in + * the ZIP headers are whatever the sender wrote; counting inflated bytes is + * what stops a ZIP bomb. + */ +function readCapped(entry: JSZip.JSZipObject, maxBytes: number): Promise { + return new Promise((resolve) => { + const chunks: Uint8Array[] = []; + let total = 0; + let done = false; + const finish = (value: Uint8Array | undefined) => { + if (done) return; + done = true; + resolve(value); + }; + let stream: ZipStream; + try { + // internalStream is part of jszip's API but missing from its typings. + stream = (entry as unknown as { internalStream(type: 'uint8array'): ZipStream }).internalStream('uint8array'); + } catch { + finish(undefined); + return; + } + stream + .on('data', (chunk) => { + if (done) return; + total += chunk.byteLength; + if (total > maxBytes) { + stream.pause(); + finish(undefined); + return; + } + chunks.push(chunk); + }) + .on('error', () => finish(undefined)) + .on('end', () => { + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + finish(out); + }) + .resume(); + }); +} + +/** + * The parsed pass.json of a .pkpass archive, or undefined for anything + * doubtful: not a ZIP, more than 100 entries, no pass.json, pass.json over + * 256 KB once inflated, not JSON, not an object. Nothing else in the archive + * is inflated. Never throws. + */ +export async function readPassJson(bytes: ArrayBuffer | Uint8Array, options: ReadPassOptions = {}): Promise { + try { + if (bytes.byteLength === 0 || bytes.byteLength > PKPASS_MAX_BYTES) return undefined; + const JSZipClass = await (options.loadZip ?? defaultLoadZip)(); + const zip = await JSZipClass.loadAsync(bytes); + if (Object.keys(zip.files).length > PKPASS_MAX_ENTRIES) return undefined; + const entry = zip.file('pass.json'); + if (!entry || entry.dir) return undefined; + const raw = await readCapped(entry, PASS_JSON_MAX_BYTES); + if (!raw) return undefined; + // App: Hermes may lack TextDecoder's fatal mode (utf8.ts). + let text = decodeUtf8Strict(raw); + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); + const parsed: unknown = JSON.parse(text); + return isObj(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +const PASS_STYLES = ['boardingPass', 'coupon', 'eventTicket', 'generic', 'storeCard'] as const; + +const TRANSIT_KINDS: Record = { + PKTransitTypeAir: 'flight', + PKTransitTypeTrain: 'train', + PKTransitTypeBus: 'bus', +}; + +/** Thrown inside the mapping when a value is present but can't be trusted. */ +class Doubtful extends Error {} + +function doubt(): never { + throw new Doubtful(); +} + +/** A string tag: absent → undefined, present but not text → doubtful. */ +function text(semantics: Obj, key: string): string | undefined { + const value = semantics[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string' && typeof value !== 'number') doubt(); + return str(value); +} + +/** A date tag: absent → undefined, present but not ISO 8601 → doubtful. */ +function date(semantics: Obj, key: string): Moment | undefined { + const value = semantics[key]; + if (value === undefined || value === null || value === '') return undefined; + return moment(value) ?? doubt(); +} + +/** + * A time as local to its place. Passes often give times in UTC (`Z`, + * `+00:00`), which fixes the instant but not the wall clock. With the pass's + * IANA zone for the place (departureLocationTimeZone, + * destinationLocationTimeZone) the instant is turned into that zone's wall + * clock; for a flight without one, the moment is marked `instantOnly` and the + * airport's zone supplies the wall clock later (resolve.ts); for anything + * else it is doubtful. A zone given next to a non-UTC offset has to agree + * with it, otherwise one of the two is wrong. + */ +function placeTime(semantics: Obj, zoneKey: string | undefined, at: Moment | undefined, airport: boolean): Moment | undefined { + const tag = zoneKey ? semantics[zoneKey] : undefined; + let zone: string | undefined; + if (tag !== undefined && tag !== null) { + if (typeof tag !== 'string' || !isValidTimeZone(tag)) doubt(); + zone = tag; + } + if (!at?.utc || at.offsetMinutes === undefined) return at; + const ms = Date.parse(at.utc); + if (at.offsetMinutes === 0) { + if (zone) { + const local = wallClockAt(ms, zone); + return { date: local.date, time: local.time, offsetMinutes: zoneOffsetAt(zone, ms), utc: at.utc }; + } + if (airport) return { ...at, instantOnly: true }; + doubt(); + } + if (zone && zoneOffsetAt(zone, ms) !== at.offsetMinutes) doubt(); + return at; +} + +function sameTime(a: Moment, b: Moment): boolean { + if (a.utc && b.utc) return a.utc === b.utc; + return a.date === b.date && (a.time ?? '') === (b.time ?? ''); +} + +/** + * transitStatus is free text. Only a status that clearly says the trip runs + * as planned keeps the card; "Delayed 40 min", "Diverted", "fällt aus" or + * anything unknown leaves the times in doubt. + */ +const PLANNED_STATUS = new Set(['on time', 'pünktlich', 'boarding', 'scheduled', 'planmäßig']); + +function plannedStatus(status: string | undefined): boolean { + if (status === undefined) return true; + const normal = status.normalize('NFC').toLowerCase().replace(/[-_]+/g, ' ').replace(/[.!]+$/, '').replace(/\s+/g, ' ').trim(); + return normal === '' || PLANNED_STATUS.has(normal); +} + +/** PersonNameComponents → "Given Family". */ +function personName(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (!isObj(value)) doubt(); + const parts = [str(value.givenName), str(value.middleName), str(value.familyName)].filter((p): p is string => !!p); + return parts.length > 0 ? parts.join(' ') : undefined; +} + +/** Split the IATA flight: airlineCode + flightNumber, flightCode ("LH400"), or both when they agree. */ +function flightIdentity(semantics: Obj): { carrierCode: string; number: string } { + const airline = text(semantics, 'airlineCode')?.toUpperCase(); + if (airline !== undefined && !/^[A-Z0-9]{2}$/.test(airline)) doubt(); + const numberRaw = text(semantics, 'flightNumber'); + if (numberRaw !== undefined && !/^\d{1,4}$/.test(numberRaw)) doubt(); + const number = numberRaw !== undefined ? String(Number(numberRaw)) : undefined; + const codeRaw = text(semantics, 'flightCode')?.replace(/\s+/g, '').toUpperCase(); + let fromCode: { carrierCode: string; number: string } | undefined; + if (codeRaw !== undefined) { + const match = /^([A-Z0-9]{2})(\d{1,4})$/.exec(codeRaw); + if (!match || !/[A-Z]/.test(match[1])) doubt(); + fromCode = { carrierCode: match[1], number: String(Number(match[2])) }; + } + const carrierCode = airline ?? fromCode?.carrierCode; + const flight = number ?? fromCode?.number; + if (!carrierCode || !flight) doubt(); + if (fromCode && (fromCode.carrierCode !== carrierCode || fromCode.number !== flight)) doubt(); + return { carrierCode, number: flight }; +} + +function airport(semantics: Obj, prefix: 'departure' | 'destination', codeRequired: boolean): Place { + const code = text(semantics, `${prefix}AirportCode`)?.toUpperCase(); + if (code !== undefined && !/^[A-Z]{3}$/.test(code)) doubt(); + if (codeRequired && !code) doubt(); + const name = text(semantics, `${prefix}AirportName`); + return { ...(name ? { name } : {}), ...(code ? { code } : {}) }; +} + +function station(semantics: Obj, prefix: 'departure' | 'destination'): Place { + const name = text(semantics, `${prefix}StationName`) ?? text(semantics, `${prefix}LocationDescription`); + return name ? { name } : {}; +} + +/** Seats as the markup writes them: "12A" for flights, "section/number" otherwise. */ +function seats(semantics: Obj, kind: TransportKind, passenger: string | undefined): Seat[] { + const value = semantics.seats; + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) doubt(); + const car = kind === 'train' ? text(semantics, 'carNumber') : undefined; + const out: Seat[] = []; + for (const entry of value) { + if (!isObj(entry)) doubt(); + const number = str(entry.seatNumber); + const row = str(entry.seatRow); + const identifier = str(entry.seatIdentifier); + let seat: string | undefined; + if (kind === 'flight') { + if (number && row) { + if (number.toUpperCase().startsWith(row.toUpperCase())) seat = number.toUpperCase(); + else if (/^\d{1,3}$/.test(row) && /^[A-Z]$/i.test(number)) seat = `${row}${number.toUpperCase()}`; + // Any other combination is left out rather than guessed. + } else { + seat = (number ?? identifier)?.toUpperCase(); + } + } else { + const base = number ?? identifier; + const section = str(entry.seatSection) ?? car; + seat = base && section ? `${section}/${base}` : base; + } + if (seat) out.push({ ...(passenger ? { passenger } : {}), seat }); + } + return out; +} + +function transportCard(kind: TransportKind, pass: Obj, semantics: Obj): TransportBooking { + if (!plannedStatus(text(semantics, 'transitStatus'))) doubt(); + + const flight = kind === 'flight'; + const departureTime = (key: string) => placeTime(semantics, 'departureLocationTimeZone', date(semantics, key), flight); + const arrivalTime = (key: string) => placeTime(semantics, 'destinationLocationTimeZone', date(semantics, key), flight); + const original = departureTime('originalDepartureDate'); + const current = departureTime('currentDepartureDate'); + // A departure moved to another day would make the pass describe another leg. + if (original && current && original.date !== current.date) doubt(); + const departureAt = current ?? original; + if (!departureAt) doubt(); + // The scheduled arrival belongs to the scheduled departure only; after a + // change, only the updated arrival (if any) goes with the updated departure. + const onSchedule = !current || (!!original && sameTime(current, original)); + const arrivalAt = arrivalTime('currentArrivalDate') ?? (onSchedule ? arrivalTime('originalArrivalDate') : undefined); + + const passenger = personName(semantics.passengerName); + const provider = text(semantics, 'transitProvider'); + const organization = str(pass.organizationName); + const reference = text(semantics, 'confirmationNumber'); + + let leg: TransportLeg; + if (kind === 'flight') { + const { carrierCode, number } = flightIdentity(semantics); + leg = { + ...(provider ? { carrier: provider } : {}), + carrierCode, + number, + departure: { place: airport(semantics, 'departure', true), at: departureAt }, + arrival: { place: airport(semantics, 'destination', false), ...(arrivalAt ? { at: arrivalAt } : {}) }, + seats: seats(semantics, kind, passenger), + }; + } else { + const number = text(semantics, 'vehicleNumber'); + leg = { + ...(provider ? { carrier: provider } : {}), + ...(number ? { number } : {}), + departure: { place: station(semantics, 'departure'), at: departureAt }, + arrival: { place: station(semantics, 'destination'), ...(arrivalAt ? { at: arrivalAt } : {}) }, + seats: seats(semantics, kind, passenger), + }; + } + const shownProvider = provider ?? organization; + return { + key: '', + kind, + ...(reference ? { reference } : {}), + passengers: passenger ? [passenger] : [], + ...(shownProvider ? { provider: shownProvider } : {}), + links: {}, + source: 'pkpass', + legs: [leg], + }; +} + +function eventCard(pass: Obj, semantics: Obj): EventBooking { + const name = text(semantics, 'eventName'); + // Event tickets have no zone tag, so a UTC time can't be placed: doubtful. + const start = placeTime(semantics, undefined, date(semantics, 'eventStartDate'), false); + if (!name || !start) doubt(); + let end = placeTime(semantics, undefined, date(semantics, 'eventEndDate'), false); + // A date-only end next to a timed start says nothing reliable about when it ends. + if (end && !end.time && start.time) end = undefined; + const venueName = text(semantics, 'venueName'); + const region = text(semantics, 'venueRegionName'); + const venue: Place = { + ...(venueName ? { name: venueName } : {}), + ...(region ? { address: { locality: region } } : {}), + }; + const attendee = text(semantics, 'attendeeName'); + const reference = text(semantics, 'confirmationNumber'); + const organization = str(pass.organizationName); + return { + key: '', + kind: 'event', + ...(reference ? { reference } : {}), + passengers: attendee ? [attendee] : [], + ...(organization ? { provider: organization } : {}), + links: {}, + source: 'pkpass', + name, + ...(venue.name || venue.address ? { venue } : {}), + start, + ...(end ? { end } : {}), + tickets: [], + }; +} + +/** + * The cards a parsed pass.json describes: at most one, validated against + * the mail's date (validateCard), with its merge key, zones not yet resolved. + * Empty for any pass that isn't a boarding pass for air, train or bus or an + * event ticket, for a voided pass, for a pass without `semantics`, and for + * anything doubtful. Never throws. + */ +export function passToCards(pass: unknown, ctx: { mailDate: Date }): StructuredCard[] { + try { + if (!isObj(pass) || pass.formatVersion !== 1 || pass.voided === true) return []; + const styles = PASS_STYLES.filter((style) => pass[style] !== undefined); + if (styles.length !== 1) return []; + const semantics = pass.semantics; + if (!isObj(semantics)) return []; + let card: StructuredCard; + if (styles[0] === 'boardingPass') { + const boarding = pass.boardingPass; + if (!isObj(boarding) || typeof boarding.transitType !== 'string') return []; + const kind = TRANSIT_KINDS[boarding.transitType]; + if (!kind) return []; + card = transportCard(kind, pass, semantics); + } else if (styles[0] === 'eventTicket') { + if (!isObj(pass.eventTicket)) return []; + card = eventCard(pass, semantics); + } else { + return []; + } + const keyed = { ...card, key: cardKey(card) } as StructuredCard; + return validateCard(keyed, ctx.mailDate).problems.length === 0 ? [keyed] : []; + } catch { + return []; + } +} + +/** readPassJson + passToCards. */ +export async function readPkpass(bytes: ArrayBuffer | Uint8Array, ctx: { mailDate: Date }, options?: ReadPassOptions): Promise { + const pass = await readPassJson(bytes, options); + return pass ? passToCards(pass, ctx) : []; +} diff --git a/src/lib/structured-mail/utf8.ts b/src/lib/structured-mail/utf8.ts new file mode 100644 index 00000000..21152ecf --- /dev/null +++ b/src/lib/structured-mail/utf8.ts @@ -0,0 +1,53 @@ +/** + * App only: strict UTF-8 decoding for pkpass.ts, which the webmail does with + * `new TextDecoder('utf-8', { fatal: true })`. Hermes may lack TextDecoder or + * its `fatal` option; then the bytes are decoded here, and a malformed + * sequence throws as the fatal decoder would. + */ +export function decodeUtf8Strict(bytes: Uint8Array): string { + const Decoder = (globalThis as { TextDecoder?: typeof TextDecoder }).TextDecoder; + if (Decoder) { + let decoder: TextDecoder | null = null; + try { + decoder = new Decoder('utf-8', { fatal: true }); + } catch { + decoder = null; + } + if (decoder) return decoder.decode(bytes); + } + return decodeByHand(bytes); +} + +/** UTF-8 to a string, throwing on overlong, surrogate, out-of-range or cut-off sequences. */ +export function decodeByHand(bytes: Uint8Array): string { + const parts: string[] = []; + let chunk: number[] = []; + const flush = () => { + if (chunk.length > 0) parts.push(String.fromCodePoint(...chunk)); + chunk = []; + }; + let i = 0; + while (i < bytes.length) { + const b0 = bytes[i]; + let cp: number; + let need: number; + let min: number; + if (b0 < 0x80) { cp = b0; need = 0; min = 0; } + else if (b0 >= 0xc2 && b0 <= 0xdf) { cp = b0 & 0x1f; need = 1; min = 0x80; } + else if (b0 >= 0xe0 && b0 <= 0xef) { cp = b0 & 0x0f; need = 2; min = 0x800; } + else if (b0 >= 0xf0 && b0 <= 0xf4) { cp = b0 & 0x07; need = 3; min = 0x10000; } + else throw new TypeError('invalid UTF-8'); + // A missing continuation byte (the input ends mid-sequence) is undefined here. + for (let k = 1; k <= need; k++) { + const b = bytes[i + k]; + if (b === undefined || (b & 0xc0) !== 0x80) throw new TypeError('invalid UTF-8'); + cp = (cp << 6) | (b & 0x3f); + } + if (cp < min || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) throw new TypeError('invalid UTF-8'); + chunk.push(cp); + if (chunk.length >= 4096) flush(); + i += need + 1; + } + flush(); + return parts.join(''); +}