mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: show booking cards for Apple Wallet passes attached to verified mail
This commit is contained in:
@@ -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>): 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<string, Uint8Array | Error>) {
|
||||
return vi.fn<FetchPassBytes>(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/);
|
||||
});
|
||||
});
|
||||
@@ -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<ArrayBufferLike>, 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 = '<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>`;
|
||||
|
||||
/** 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<string, unknown> = 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ArrayBuffer>;
|
||||
|
||||
/** 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);
|
||||
@@ -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<StructuredCard[]>;
|
||||
|
||||
/** Registered sources. */
|
||||
export const CARD_SOURCES: CardSource[] = [];
|
||||
/** Registered sources: Apple Wallet passes. */
|
||||
export const CARD_SOURCES: CardSource[] = [pkpassSource];
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<string, string> } = {}): Promise<Uint8Array> {
|
||||
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' });
|
||||
}
|
||||
@@ -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 = `<html><head><script type="application/ld+json">${JSON.stringify({
|
||||
'@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'PK7Q2Z',
|
||||
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: '2026-12-03T07:40:00+01:00',
|
||||
arrivalAirport: { '@type': 'Airport', name: 'Berlin Brandenburg', iataCode: 'BER' }, arrivalTime: '2026-12-03T09:05:00+01:00',
|
||||
},
|
||||
})}</script></head><body></body></html>`;
|
||||
|
||||
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<string, (...args: unknown[]) => 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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof JSZip>;
|
||||
}
|
||||
|
||||
async function defaultLoadZip(): Promise<typeof JSZip> {
|
||||
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<Uint8Array | undefined> {
|
||||
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<Obj | undefined> {
|
||||
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<string, TransportKind> = {
|
||||
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<StructuredCard[]> {
|
||||
const pass = await readPassJson(bytes, options);
|
||||
return pass ? passToCards(pass, ctx) : [];
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
Reference in New Issue
Block a user