feat: read Wallet passes attached to trusted booking mail

This commit is contained in:
Linus Rath
2026-10-30 05:01:02 +01:00
parent 63830d4033
commit 66c73d0f4d
4 changed files with 179 additions and 2 deletions
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Attachment, Email } from '@/lib/jmap/types';
import { MAIL_DATE, boardingPass, eventTicket, zipPass } from '@/lib/structured-mail/__tests__/pkpass-fixtures';
import { pkpassSource } from '../pkpass-source';
import { CARD_SOURCES } from '../sources';
const PASS_ATT: Attachment = { partId: '2', blobId: 'blob-pass', size: 3000, name: 'BoardingPass.pkpass', type: 'application/octet-stream' };
const PDF_ATT: Attachment = { partId: '3', 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, receivedAt: MAIL_DATE.toISOString(), ...fields } as Email;
}
function clientFor(blobs: Record<string, Uint8Array | Error>) {
const fetchBlobArrayBuffer = vi.fn(async (blobId: string) => {
const blob = blobs[blobId];
if (!blob) throw new Error(`no blob ${blobId}`);
if (blob instanceof Error) throw blob;
return blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength) as ArrayBuffer;
});
return { client: { fetchBlobArrayBuffer } as unknown as IJMAPClient, fetchBlobArrayBuffer };
}
function run(email: Email, client: IJMAPClient | null, signal = new AbortController().signal) {
return pkpassSource({ email, mailDate: MAIL_DATE, blob: { client, 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 { client, fetchBlobArrayBuffer } = clientFor({ 'blob-pass': await zipPass(boardingPass()) });
const cards = await run(mail({ attachments: [PDF_ATT, PASS_ATT] }), client);
expect(cards).toMatchObject([{ kind: 'flight', source: 'pkpass', reference: 'PK7Q2Z' }]);
expect(fetchBlobArrayBuffer).toHaveBeenCalledTimes(1);
expect(fetchBlobArrayBuffer).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}`, partId: `p${i}` }));
const { client, fetchBlobArrayBuffer } = clientFor({ p1: bytes, p2: bytes, p3: bytes, p4: bytes });
await run(mail({ attachments: [PDF_ATT, { ...PASS_ATT, blobId: 'big', size: 6 * 1024 * 1024 }, ...passes] }), client);
expect(fetchBlobArrayBuffer.mock.calls.map((c) => c[0])).toEqual(['p1', 'p2', 'p3']);
});
it('reads email.attachments only, never a pass inside an attached message', async () => {
const { client, fetchBlobArrayBuffer } = clientFor({ 'blob-pass': await zipPass(boardingPass()) });
const embedded = mail({
attachments: [{ partId: '2', 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, client)).toEqual([]);
expect(fetchBlobArrayBuffer).not.toHaveBeenCalled();
});
it('stops between downloads once the message is closed', async () => {
const bytes = await zipPass(boardingPass());
const controller = new AbortController();
const { client, fetchBlobArrayBuffer } = clientFor({ p1: bytes, p2: bytes });
fetchBlobArrayBuffer.mockImplementationOnce(async () => {
controller.abort();
return bytes.buffer.slice(0) as ArrayBuffer;
});
const cards = await run(mail({ attachments: [{ ...PASS_ATT, blobId: 'p1' }, { ...PASS_ATT, blobId: 'p2' }] }), client, controller.signal);
expect(cards).toEqual([]);
expect(fetchBlobArrayBuffer).toHaveBeenCalledTimes(1);
const late = new AbortController();
fetchBlobArrayBuffer.mockImplementationOnce(async () => {
late.abort();
return bytes.buffer.slice(0) as ArrayBuffer;
});
expect(await run(mail({ attachments: [{ ...PASS_ATT, blobId: 'p1' }] }), client, late.signal)).toEqual([]);
});
it('gives an empty list, without throwing, when the download fails or there is no client', async () => {
const { client, fetchBlobArrayBuffer } = clientFor({ 'blob-pass': new Error('Failed to fetch blob: 500') });
await expect(run(mail({ attachments: [PASS_ATT] }), client)).resolves.toEqual([]);
expect(fetchBlobArrayBuffer).toHaveBeenCalledTimes(1);
await expect(run(mail({ attachments: [PASS_ATT] }), null)).resolves.toEqual([]);
await expect(run(mail({ attachments: [{ ...PASS_ATT, blobId: '' }] }), client)).resolves.toEqual([]);
expect(fetchBlobArrayBuffer).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,48 @@
import { renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseAuthenticationResults } from '@/lib/email-headers';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, EmailBodyPart } from '@/lib/jmap/types';
import type { TransportBooking } from '@/lib/structured-mail';
import { MAIL_DATE, boardingPass, zipPass } from '@/lib/structured-mail/__tests__/pkpass-fixtures';
import { usePolicyStore } from '@/stores/policy-store';
import { useStructuredMail } from '../use-structured-mail';
const PASS = 'mx.example.org; dkim=pass header.d=swiss.example; spf=pass smtp.mailfrom=bounce@swiss.example; dmarc=pass header.from=swiss.example';
function mail(id: string, ar: string | null): Email {
return {
id, threadId: 't', mailboxIds: {}, keywords: {}, size: 1, receivedAt: MAIL_DATE.toISOString(), subject: 'Your boarding pass',
from: [{ email: 'boarding@swiss.example' }],
...(ar ? { authenticationResults: parseAuthenticationResults([ar]), authenticationResultsOnReceipt: true } : {}),
htmlBody: [{ partId: '1', type: 'text/html' } as EmailBodyPart],
bodyValues: { '1': { value: '<html><body><p>Your boarding pass is attached.</p></body></html>', isTruncated: false, isEncodingProblem: false } },
attachments: [{ partId: '2', blobId: 'blob-pass', size: 3000, name: 'BoardingPass.pkpass', type: 'application/vnd.apple.pkpass' }],
} as unknown as Email;
}
const initial = usePolicyStore.getState();
afterEach(() => {
usePolicyStore.setState(initial, true);
});
describe('useStructuredMail with a Wallet pass attached', () => {
it('shows the resolved flight card of a trusted mail, and downloads nothing for an untrusted one', async () => {
usePolicyStore.setState((state) => ({ policy: { ...state.policy, features: { ...state.policy.features, structuredMailEnabled: true } } }));
const bytes = await zipPass(boardingPass());
const fetchBlobArrayBuffer = vi.fn(async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer);
const client = { fetchBlobArrayBuffer } as unknown as IJMAPClient;
const untrusted = renderHook(() => useStructuredMail(mail('u', null), { client, accountId: 'acc-1' }));
const { result } = renderHook(() => useStructuredMail(mail('t', PASS), { client, accountId: 'acc-1' }));
await waitFor(() => expect(result.current).toHaveLength(1));
const [card] = result.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(fetchBlobArrayBuffer).toHaveBeenCalledTimes(1);
expect(fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-pass', 'BoardingPass.pkpass', 'application/vnd.apple.pkpass', 'acc-1', 5 * 1024 * 1024 + 1);
expect(untrusted.result.current).toEqual([]);
});
});
@@ -0,0 +1,36 @@
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';
/**
* 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 const pkpassSource: CardSource = async ({ email, mailDate, blob, signal }) => {
try {
const { client, accountId } = blob;
if (!client || 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 client.fetchBlobArrayBuffer(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 [];
}
};
+3 -2
View File
@@ -1,6 +1,7 @@
import type { Email } from '@/lib/jmap/types';
import type { StructuredCard } from '@/lib/structured-mail';
import type { EmailBlobContext } from '@/stores/email-store';
import { pkpassSource } from './pkpass-source';
/**
* Card sources beyond the HTML markup, read asynchronously once a trusted
@@ -20,5 +21,5 @@ export interface CardSourceContext {
export type CardSource = (context: CardSourceContext) => Promise<StructuredCard[]>;
/** Registered sources; each one appends itself here. */
export const CARD_SOURCES: CardSource[] = [];
/** Registered sources: Apple Wallet passes. */
export const CARD_SOURCES: CardSource[] = [pkpassSource];