diff --git a/components/email/structured-mail/__tests__/pkpass-source.test.ts b/components/email/structured-mail/__tests__/pkpass-source.test.ts new file mode 100644 index 000000000..b2b6bd798 --- /dev/null +++ b/components/email/structured-mail/__tests__/pkpass-source.test.ts @@ -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 { + return { id: 'm1', threadId: 't', mailboxIds: {}, keywords: {}, size: 1, receivedAt: MAIL_DATE.toISOString(), ...fields } as Email; +} + +function clientFor(blobs: Record) { + 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); + }); +}); diff --git a/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.tsx b/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.tsx new file mode 100644 index 000000000..35ee5c7ed --- /dev/null +++ b/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.tsx @@ -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: '

Your boarding pass is attached.

', isTruncated: false, isEncodingProblem: false } }, + attachments: [{ partId: '2', blobId: 'blob-pass', size: 3000, name: 'BoardingPass.pkpass', type: 'application/vnd.apple.pkpass' }], + } as unknown as Email; +} + +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([]); + }); +}); diff --git a/components/email/structured-mail/pkpass-source.ts b/components/email/structured-mail/pkpass-source.ts new file mode 100644 index 000000000..02afc4d60 --- /dev/null +++ b/components/email/structured-mail/pkpass-source.ts @@ -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 []; + } +}; diff --git a/components/email/structured-mail/sources.ts b/components/email/structured-mail/sources.ts index 5ebc2edac..f79e9e312 100644 --- a/components/email/structured-mail/sources.ts +++ b/components/email/structured-mail/sources.ts @@ -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; -/** Registered sources; each one appends itself here. */ -export const CARD_SOURCES: CardSource[] = []; +/** Registered sources: Apple Wallet passes. */ +export const CARD_SOURCES: CardSource[] = [pkpassSource];