mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: read Wallet passes attached to trusted booking mail
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
};
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user