diff --git a/docs/parity/11-webmail-1.19.md b/docs/parity/11-webmail-1.19.md index 6f84ae82..31968f72 100644 --- a/docs/parity/11-webmail-1.19.md +++ b/docs/parity/11-webmail-1.19.md @@ -24,7 +24,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`; - What RN does: MessageHeader works out the verdict (`src/components/email/MessageHeader.tsx:118`) but renders SenderAvatar with the claimed address (`:175`); the same in thread cards (`src/components/email/ThreadMessageCard.tsx:50`) and list rows (`src/screens/EmailListScreen.tsx:213`). SenderAvatar picks a contact photo or the domain favicon (`src/components/SenderAvatar.tsx:62-65`). List rows have no auth data (`src/api/email.ts:23-26`). - Fix hint: pass a sender warning from getSenderVerification into SenderAvatar and draw a shield; for list rows add `header:Authentication-Results:all` to the list properties. -- [ ] **No booking, parcel or order cards from schema.org markup** — `P2` — `missing` +- [x] **No booking, parcel or order cards from schema.org markup** — `P2` — `missing` — fixed in 5f2ef4c1, a6066a09, cd677e77, 2055df8c, 982a30ef, c90a35c4 (mount at merge) - What WEB does: reads JSON-LD/microdata booking markup and attached Wallet passes and shows a card above the message (flight, train, bus, hotel, rental car, event, restaurant, parcel tracking, order status) with copy buttons and links that show where they lead. Bookings go into a calendar the user picks with stable UIDs, so a later mail updates the event and a cancellation removes it. Gate: the receiving server verified the From domain, not a forward, not a personal mailbox provider, not turned off by the admin (`lib/structured-mail/index.ts:36`, `trust.ts:104`, `pkpass.ts`, `calendar.ts:147`, `components/email/structured-mail/use-booking-calendar.ts:115`, `lib/structured-mail-gate.ts:7`). a529e6e57..859d368a1. - What RN does: nothing; the only banners above the body are unsubscribe and invitation (`src/components/email/MessageContent.tsx:104`, `:112`). - Fix hint: port `lib/structured-mail/` with an RN-side HTML parser passed through `parseHtml` (`lib/structured-mail/index.ts:4-5`, `:21`) and build RN cards. The admin switch needs the org policy, so default on. diff --git a/locales/rn/en.json b/locales/rn/en.json index fdddaccb..d2c7cbf8 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -576,6 +576,69 @@ "spam_short": "Spam", "still_scheduled": "The message is still scheduled.", "still_scheduled_at": "The message is still scheduled for {date}.", + "structured_mail": { + "check_in_label": "Check-in", + "check_out_label": "Check-out", + "copied_order_number": "Order number copied", + "copied_reference": "Booking reference copied", + "copied_tracking_number": "Tracking number copied", + "copy_failed": "Could not copy to the clipboard", + "copy_order_number": "Copy order number {value}", + "copy_reference": "Copy booking reference {value}", + "copy_tracking_number": "Copy tracking number {value}", + "dropoff": "Return", + "expected_by": "Arrives by {date}", + "from_time": "from {time}", + "items_count": "{count, plural, one {# item} other {# items}}", + "items_more": "and {count} more", + "kind_bus": "Bus", + "kind_event": "Event", + "kind_flight": "Flight", + "kind_lodging": "Hotel", + "kind_order": "Order", + "kind_parcel": "Parcel", + "kind_rentalcar": "Rental car", + "kind_restaurant": "Restaurant", + "kind_train": "Train", + "link_check_in": "Check in", + "link_manage": "Manage booking", + "link_ticket": "Ticket", + "link_track": "Track", + "link_view_order": "View order", + "local_time": "local time", + "local_time_title": "Local time at the place, not your time zone", + "nights": "{count, plural, one {# night} other {# nights}}", + "order_number": "Order number", + "order_status_cancelled": "Cancelled", + "order_status_delivered": "Delivered", + "order_status_in_transit": "Shipped", + "order_status_payment_due": "Payment due", + "order_status_pickup_available": "Ready for pickup", + "order_status_problem": "Problem", + "order_status_processing": "Processing", + "order_status_returned": "Returned", + "party_size": "{count, plural, one {Table for #} other {Table for #}}", + "passengers": "Passengers", + "pickup": "Pick-up", + "reference": "Booking reference", + "region_bus": "Bus booking", + "region_event": "Event booking", + "region_flight": "Flight booking", + "region_lodging": "Hotel booking", + "region_rentalcar": "Rental car booking", + "region_restaurant": "Restaurant reservation", + "region_shipment": "Order and delivery", + "region_train": "Train booking", + "route": "{from} to {to}", + "seats": "Seats", + "status_cancelled": "Cancelled", + "status_hold": "On hold", + "status_pending": "Pending", + "tickets": "Tickets", + "total": "Total {total}", + "tracking_number": "Tracking number", + "until_time": "until {time}" + }, "thread_loading": "Loading conversation...", "undo_send_failed": "Undo send failed", "unsubscribe_banner": { @@ -1559,6 +1622,36 @@ "sign_out_confirm_title_account": "Sign out of {account}?", "tab_unavailable": "{name} (unavailable)" }, + "structured_mail_calendar": { + "action_failed": "Couldn't change your calendar. Try again.", + "add_to_calendar": "Add to calendar", + "added": "Added to your calendar", + "description": { + "added_from": "Added from a mail from {domain}", + "added_from_subject": "Added from the mail “{subject}” from {domain}", + "car": "Car: {car}", + "checkin": "Check in: {url}", + "manage": "Manage booking: {url}", + "party_size": "Party size: {count}", + "passengers": "Passengers: {names}", + "phone": "Phone: {phone}", + "reference": "Booking reference: {reference}", + "seats": "Seats: {seats}", + "ticket": "Ticket: {url}", + "tickets": "Tickets: {numbers}" + }, + "in_calendar": "In your calendar", + "remove_from_calendar": "Remove from calendar", + "removed": "Removed from your calendar", + "select_calendar": "Choose a calendar", + "title": { + "rental_car": "Rental car", + "rental_car_company": "Rental car: {company}" + }, + "update_in_calendar": "Update in calendar", + "updated": "Calendar updated", + "view_in_calendar": "View in calendar" + }, "templates": { "manage": "Manage templates" }, diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index 6cda02e3..82edff70 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -1099,4 +1099,120 @@ live('live Stalwart calendar', () => { } }, 60_000); }); + + describe('a booking mail in the calendar: added, moved by a later mail, removed by a cancellation', () => { + // A train booking 2.5 months ahead (within what the cards accept), from a + // sender whose Authentication-Results pass. Imported, the headers stay + // as written, which is enough for the trust gate (see receipt.ts). + const reference = `R${RUN.slice(-8).toUpperCase()}`; + const imported: string[] = []; + let uid = ''; + + function bookingMail(departure: string, arrival: string, status = 'ReservationConfirmed'): string { + const markup = { + '@context': 'http://schema.org', '@type': 'TrainReservation', reservationNumber: reference, + reservationStatus: `http://schema.org/${status}`, underName: { '@type': 'Person', name: 'Erika Mustermann' }, + reservationFor: { + '@type': 'TrainTrip', trainNumber: 'ICE 577', provider: { '@type': 'Organization', name: 'Rail Co' }, + departureStation: { '@type': 'TrainStation', name: 'Hamburg Hbf', address: { addressLocality: 'Hamburg', addressCountry: 'DE' } }, + departureTime: departure, + arrivalStation: { '@type': 'TrainStation', name: 'Berlin Hbf', address: { addressLocality: 'Berlin', addressCountry: 'DE' } }, + arrivalTime: arrival, + }, + }; + return [ + 'Received: from mail.rail.example (mail.rail.example [192.0.2.7]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1', + 'Authentication-Results: mx.example.org; dkim=pass header.d=rail.example header.s=s1; spf=pass smtp.mailfrom=bounce@rail.example; dmarc=pass header.from=rail.example', + 'From: Rail Co ', + `To: ${USER}`, + `Subject: ${RUN} booking ${reference} ${status}`, + `Date: ${new Date().toUTCString()}`, + `Message-ID: <${RUN}-${status}-${departure.slice(11, 13)}@rail.example>`, + 'MIME-Version: 1.0', + 'Content-Type: text/html; charset=utf-8', + '', + `

Your booking ${reference}

`, + '', + ].join('\r\n'); + } + + /** Import a booking mail, read it as the reader does and hand back its one card. */ + async function receive(message: string) { + const [{ uploadBytes }, mail, sm, trust] = await Promise.all([ + import('../blob'), import('../email'), import('../../lib/structured-mail'), import('../../lib/structured-mail/trust'), + ]); + const [[, boxes]] = await raw(client, [['Mailbox/get', { accountId: client.accountId, properties: ['id', 'role'] }, '0']], MAIL_USING); + const inbox = (boxes.list as { id: string; role?: string }[]).find((b) => b.role === 'inbox')!.id; + const { blobId } = await uploadBytes(new TextEncoder().encode(message), 'message/rfc822'); + const id = await mail.importEmailBlob(blobId, inbox, { $seen: true }); + imported.push(id); + const email = await mail.getFullEmail(id); + expect(trust.getStructuredMailTrust(email)).toEqual({ trusted: true }); + const cards = sm.resolveCards(sm.readStructuredMail(trust.structuredHtmlPart(email)!.value, { mailDate: new Date(email.sentAt ?? email.receivedAt) })); + expect(cards).toHaveLength(1); + return { email, card: cards[0] }; + } + + /** The booking calendar hook of the card, settled on `status`. */ + async function cardCalendar(message: string, status: string) { + const [{ useBookingCalendar }, harness] = await Promise.all([ + import('../../components/email/structured-mail/use-booking-calendar'), + import('../../components/email/structured-mail/__tests__/render-native'), + ]); + const { email, card } = await receive(message); + const hook = harness.renderHook(() => useBookingCalendar(card, email)); + await harness.waitFor(() => expect(hook.current.status).toBe(status), { timeout: 15_000 }); + return hook; + } + + beforeAll(async () => { + await useCalendarStore.getState().fetchCalendars(); + }, 30_000); + + afterAll(async () => { + const c = await singleton(); + if (imported.length > 0) { + await raw(c, [['Email/set', { accountId: c.accountId, destroy: imported }, '0']], MAIL_USING).catch(() => undefined); + } + if (uid) { + const { findEventsByUid } = await import('../calendar'); + const left = await findEventsByUid(uid).catch(() => [] as CalendarEvent[]); + if (left.length > 0) await deleteEvents(left.map((e) => e.id)).catch(() => undefined); + } + }, 60_000); + + it('adds the booking, moves its event for the changed mail and removes it for the cancellation', async () => { + const { findEventsByUid } = await import('../calendar'); + + const first = await cardCalendar(bookingMail('2027-01-15T08:46:00+01:00', '2027-01-15T12:50:00+01:00'), 'add'); + await first.current.add(); + expect(first.current.error).toBeNull(); + expect(first.current.status).toBe('in-calendar'); + const created = useCalendarStore.getState().events.find((e) => e.description?.includes(reference)); + expect(created, 'the store holds the new event').toBeTruthy(); + uid = created!.uid; + let onServer = await findEventsByUid(uid); + expect(onServer).toHaveLength(1); + expect(onServer[0]).toMatchObject({ start: '2027-01-15T08:46:00', timeZone: 'Europe/Berlin', duration: 'PT4H4M' }); + first.unmount(); + + // A later mail moves the train by two hours: same booking, same uid. + const moved = await cardCalendar(bookingMail('2027-01-15T10:46:00+01:00', '2027-01-15T14:50:00+01:00'), 'update'); + await moved.current.update(); + expect(moved.current.error).toBeNull(); + expect(moved.current.status).toBe('in-calendar'); + onServer = await findEventsByUid(uid); + expect(onServer).toHaveLength(1); + expect(onServer[0]).toMatchObject({ start: '2027-01-15T10:46:00', timeZone: 'Europe/Berlin', duration: 'PT4H4M' }); + moved.unmount(); + + // The cancellation takes it out. + const cancelled = await cardCalendar(bookingMail('2027-01-15T10:46:00+01:00', '2027-01-15T14:50:00+01:00', 'ReservationCancelled'), 'remove'); + await cancelled.current.remove(); + expect(cancelled.current.error).toBeNull(); + expect(cancelled.current.status).toBe('cancelled'); + expect(await findEventsByUid(uid)).toEqual([]); + cancelled.unmount(); + }, 90_000); + }); }); diff --git a/src/components/email/structured-mail/StructuredMailBanner.tsx b/src/components/email/structured-mail/StructuredMailBanner.tsx new file mode 100644 index 00000000..136bc369 --- /dev/null +++ b/src/components/email/structured-mail/StructuredMailBanner.tsx @@ -0,0 +1,52 @@ +// App only: the booking, parcel and order cards for MessageContent, in one +// component so the reader mounts them with one line (the webmail wires the +// same pieces in email-viewer.tsx). +import React from 'react'; +import type { Email } from '../../../api/types'; +import type { Booking } from '../../../lib/structured-mail'; +import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../../../lib/trusted-senders'; +import { useHasContacts } from '../../../lib/capabilities'; +import { useContactsStore } from '../../../stores/contacts-store'; +import { useSettingsStore } from '../../../stores/settings-store'; +import { BookingCalendarActions } from './booking-calendar-actions'; +import { StructuredMailCards } from './structured-mail-cards'; +import { useStructuredMail } from './use-structured-mail'; + +interface Props { + email: Email; + /** Account the message lives in (a shared mailbox's owner); undefined for the user's own. */ + jmapAccountId?: string; +} + +/** + * Cards from the message's schema.org markup and Wallet passes, for a sender + * the receiving server verified. Nothing for any other message. Product + * images load only when the body's remote content would load without a tap: + * the policy allows it or the sender is trusted. Each booking card offers to + * add, update or remove its events in the calendar. + */ +export function StructuredMailBanner({ email, jmapAccountId }: Props) { + const cards = useStructuredMail(email, jmapAccountId); + const policy = useSettingsStore((s) => s.externalContentPolicy); + const trustedSenders = useSettingsStore((s) => s.trustedSenders); + const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted); + const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); + const trustedBookEmails = useContactsStore((s) => s.trustedSenderEmails); + const hasContacts = useHasContacts(); + const sender = email.from?.[0]?.email; + const allowRemoteImages = React.useMemo( + () => policy === 'allow' || isSenderContentTrusted(sender, { + isLocallyTrusted: isSenderTrusted, + syncEnabled: isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts), + trustedBookEmails, + }), + // trustedSenders changes what isSenderTrusted answers. + [policy, sender, isSenderTrusted, trustedSenders, trustedSendersAddressBook, hasContacts, trustedBookEmails], // eslint-disable-line react-hooks/exhaustive-deps + ); + const calendarActions = React.useCallback( + (booking: Booking) => , + [email], + ); + if (cards.length === 0) return null; + return ; +} diff --git a/src/components/email/structured-mail/__tests__/pkpass-source.test.ts b/src/components/email/structured-mail/__tests__/pkpass-source.test.ts new file mode 100644 index 00000000..10a9f01d --- /dev/null +++ b/src/components/email/structured-mail/__tests__/pkpass-source.test.ts @@ -0,0 +1,126 @@ +// Port of webmail components/email/structured-mail/__tests__/ +// pkpass-source.test.ts at 66c73d0f4, plus the app's download (the JMAP +// client mocked): the cap is enforced on the announced and the real size. +import { describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.hoisted(() => ({ respond: (_url: string): Response => new Response(null, { status: 404 }) })); +vi.mock('../../../../api/jmap-client', () => ({ + BLOB_TIMEOUT_MS: 300_000, + jmapClient: { + getBlobDownloadUrl: (blobId: string, name?: string, type?: string, accountId?: string) => `https://jmap.example/${accountId ?? 'primary'}/${blobId}/${name}?type=${type}`, + authenticatedFetch: vi.fn(async (url: string) => fetchMock.respond(url)), + }, +})); + +import type { Attachment, Email } from '../../../../api/types'; +import { jmapClient } from '../../../../api/jmap-client'; +import { MAIL_DATE, boardingPass, eventTicket, zipPass } from '../../../../lib/structured-mail/__tests__/pkpass-fixtures'; +import { fetchPassBytes, makePkpassSource, pkpassSource, type FetchPassBytes } from '../pkpass-source'; +import { CARD_SOURCES } from '../sources'; + +const PASS_ATT: Attachment = { blobId: 'blob-pass', size: 3000, name: 'BoardingPass.pkpass', type: 'application/octet-stream' }; +const PDF_ATT: Attachment = { blobId: 'blob-pdf', size: 3000, name: 'invoice.pdf', type: 'application/pdf' }; + +function mail(fields: Partial): Email { + return { id: 'm1', threadId: 't', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: true, receivedAt: MAIL_DATE.toISOString(), ...fields } as Email; +} + +const toBuffer = (bytes: Uint8Array) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + +function fetcherFor(blobs: Record) { + return vi.fn(async (blobId: string) => { + const blob = blobs[blobId]; + if (!blob) throw new Error(`no blob ${blobId}`); + if (blob instanceof Error) throw blob; + return toBuffer(blob); + }); +} + +function run(email: Email, fetchBytes: FetchPassBytes, signal = new AbortController().signal) { + return makePkpassSource(fetchBytes)({ email, mailDate: MAIL_DATE, accountId: 'acc-7', signal }); +} + +describe('pkpassSource', () => { + it('is registered as a card source', () => { + expect(CARD_SOURCES).toContain(pkpassSource); + }); + + it('downloads the pass attachment from the message account and returns its card', async () => { + const fetchBytes = fetcherFor({ 'blob-pass': await zipPass(boardingPass()) }); + const cards = await run(mail({ attachments: [PDF_ATT, PASS_ATT] }), fetchBytes); + expect(cards).toMatchObject([{ kind: 'flight', source: 'pkpass', reference: 'PK7Q2Z' }]); + expect(fetchBytes).toHaveBeenCalledTimes(1); + expect(fetchBytes).toHaveBeenCalledWith('blob-pass', 'BoardingPass.pkpass', 'application/octet-stream', 'acc-7', 5 * 1024 * 1024 + 1); + }); + + it('downloads nothing that is not a pass, and at most three passes', async () => { + const bytes = await zipPass(eventTicket()); + const passes = [1, 2, 3, 4].map((i) => ({ ...PASS_ATT, blobId: `p${i}` })); + const fetchBytes = fetcherFor({ p1: bytes, p2: bytes, p3: bytes, p4: bytes }); + await run(mail({ attachments: [PDF_ATT, { ...PASS_ATT, blobId: 'big', size: 6 * 1024 * 1024 }, ...passes] }), fetchBytes); + expect(fetchBytes.mock.calls.map((c) => c[0])).toEqual(['p1', 'p2', 'p3']); + }); + + it('reads email.attachments only, never a pass inside an attached message', async () => { + const fetchBytes = fetcherFor({ 'blob-pass': await zipPass(boardingPass()) }); + const embedded = mail({ + attachments: [{ blobId: 'blob-eml', size: 9000, name: 'forwarded.eml', type: 'message/rfc822' }], + bodyStructure: { + partId: '0', blobId: 'root', size: 9000, type: 'multipart/mixed', + subParts: [{ partId: '2', blobId: 'blob-eml', size: 9000, type: 'message/rfc822', subParts: [{ ...PASS_ATT }] }], + } as Email['bodyStructure'], + textBody: [{ ...PASS_ATT }] as Email['textBody'], + }); + expect(await run(embedded, fetchBytes)).toEqual([]); + expect(fetchBytes).not.toHaveBeenCalled(); + }); + + it('stops between downloads once the message is closed', async () => { + const bytes = await zipPass(boardingPass()); + const controller = new AbortController(); + const fetchBytes = fetcherFor({ p1: bytes, p2: bytes }); + fetchBytes.mockImplementationOnce(async () => { + controller.abort(); + return toBuffer(bytes); + }); + const cards = await run(mail({ attachments: [{ ...PASS_ATT, blobId: 'p1' }, { ...PASS_ATT, blobId: 'p2' }] }), fetchBytes, controller.signal); + expect(cards).toEqual([]); + expect(fetchBytes).toHaveBeenCalledTimes(1); + + const late = new AbortController(); + fetchBytes.mockImplementationOnce(async () => { + late.abort(); + return toBuffer(bytes); + }); + expect(await run(mail({ attachments: [{ ...PASS_ATT, blobId: 'p1' }] }), fetchBytes, late.signal)).toEqual([]); + }); + + it('gives an empty list, without throwing, when the download fails', async () => { + const fetchBytes = fetcherFor({ 'blob-pass': new Error('Failed to fetch blob: 500') }); + await expect(run(mail({ attachments: [PASS_ATT] }), fetchBytes)).resolves.toEqual([]); + expect(fetchBytes).toHaveBeenCalledTimes(1); + await expect(run(mail({ attachments: [{ ...PASS_ATT, blobId: '' }] }), fetchBytes)).resolves.toEqual([]); + expect(fetchBytes).toHaveBeenCalledTimes(1); + }); +}); + +describe('fetchPassBytes', () => { + it('downloads the blob from the message account with the client', async () => { + const bytes = await zipPass(boardingPass()); + fetchMock.respond = () => new Response(toBuffer(bytes), { status: 200, headers: { 'content-length': String(bytes.byteLength) } }); + const got = await fetchPassBytes('blob-pass', 'BoardingPass.pkpass', 'application/vnd.apple.pkpass', 'acc-7', 5 * 1024 * 1024 + 1); + expect(new Uint8Array(got)).toEqual(bytes); + expect(vi.mocked(jmapClient.authenticatedFetch)).toHaveBeenLastCalledWith( + 'https://jmap.example/acc-7/blob-pass/BoardingPass.pkpass?type=application/vnd.apple.pkpass', undefined, { timeoutMs: 300_000 }, + ); + }); + + it('refuses a blob the server says is larger than the cap, and one that turns out larger', async () => { + fetchMock.respond = () => new Response(new Uint8Array(10), { status: 200, headers: { 'content-length': '5000' } }); + await expect(fetchPassBytes('b', 'x.pkpass', 'application/vnd.apple.pkpass', undefined, 100)).rejects.toThrow(/larger/); + fetchMock.respond = () => new Response(new Uint8Array(200), { status: 200 }); + await expect(fetchPassBytes('b', 'x.pkpass', 'application/vnd.apple.pkpass', undefined, 100)).rejects.toThrow(/larger/); + fetchMock.respond = () => new Response(null, { status: 500 }); + await expect(fetchPassBytes('b', 'x.pkpass', 'application/vnd.apple.pkpass', undefined, 100)).rejects.toThrow(/500/); + }); +}); diff --git a/src/components/email/structured-mail/__tests__/render-native.ts b/src/components/email/structured-mail/__tests__/render-native.ts new file mode 100644 index 00000000..cef819f8 --- /dev/null +++ b/src/components/email/structured-mail/__tests__/render-native.ts @@ -0,0 +1,348 @@ +// A small renderer for the card tests. The app has no React renderer in its +// test setup (no react-test-renderer, no testing-library), so this walks the +// element tree, calls each function component with a stand-in hook +// dispatcher, and returns the host elements (View, Text, Pressable ...) as +// plain objects. One pass, no commits: state setters and effects do nothing +// here, which is what a snapshot of a card needs. renderHook below keeps +// state and runs effects, for hooks. +import React from 'react'; +import { vi } from 'vitest'; + +type Internals = { H: unknown }; +const internals = (React as unknown as { __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: Internals }) + .__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; + +export interface HostNode { + type: string; + props: Record; + children: Array; +} + +const MEMO = Symbol.for('react.memo'); +const FORWARD_REF = Symbol.for('react.forward_ref'); +const FRAGMENT = Symbol.for('react.fragment'); + +const oneShotDispatcher = { + useState: (init: unknown) => [typeof init === 'function' ? (init as () => unknown)() : init, () => undefined], + useReducer: (_r: unknown, init: unknown, fn?: (a: unknown) => unknown) => [fn ? fn(init) : init, () => undefined], + useMemo: (fn: () => unknown) => fn(), + useCallback: (fn: unknown) => fn, + useRef: (current: unknown) => ({ current }), + useEffect: () => undefined, + useLayoutEffect: () => undefined, + useInsertionEffect: () => undefined, + useImperativeHandle: () => undefined, + useContext: (context: { _currentValue: unknown }) => context._currentValue, + useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => getSnapshot(), + useDebugValue: () => undefined, + useId: () => ':r:', + useTransition: () => [false, (fn: () => void) => fn()], + useDeferredValue: (value: unknown) => value, +}; + +function renderNode(node: unknown, out: Array): void { + if (node === null || node === undefined || typeof node === 'boolean') return; + if (typeof node === 'string' || typeof node === 'number') { + out.push(String(node)); + return; + } + if (Array.isArray(node)) { + for (const child of node) renderNode(child, out); + return; + } + const element = node as { type: any; props: Record }; + const { type, props } = element; + if (type === FRAGMENT) { + renderNode(props.children, out); + return; + } + if (typeof type === 'string') { + const children: Array = []; + renderNode(props.children, children); + out.push({ type, props, children }); + return; + } + if (type && type.$$typeof === MEMO) { + renderNode({ type: type.type, props }, out); + return; + } + if (type && type.$$typeof === FORWARD_REF) { + renderNode(callComponent(() => type.render(props, null)), out); + return; + } + if (typeof type === 'function') { + if (type.prototype?.isReactComponent) throw new Error('class components are not supported'); + renderNode(callComponent(() => type(props)), out); + return; + } + throw new Error(`cannot render ${String(type)}`); +} + +function callComponent(fn: () => unknown): unknown { + const previous = internals.H; + internals.H = oneShotDispatcher; + try { + return fn(); + } finally { + internals.H = previous; + } +} + +/** Render `element` once into host nodes; the root holds them as children. */ +export function render(element: React.ReactElement): HostNode { + const children: Array = []; + renderNode(element, children); + return { type: 'root', props: {}, children }; +} + +/** Every host node below `node` (itself included) that `test` accepts, in tree order. */ +export function findAll(node: HostNode, test: (n: HostNode) => boolean): HostNode[] { + const out: HostNode[] = []; + const walk = (n: HostNode) => { + if (test(n)) out.push(n); + for (const child of n.children) if (typeof child !== 'string') walk(child); + }; + walk(node); + return out; +} + +export function byTestId(node: HostNode, id: string): HostNode[] { + return findAll(node, (n) => n.props.testID === id); +} + +export function oneByTestId(node: HostNode, id: string): HostNode { + const found = byTestId(node, id); + if (found.length !== 1) throw new Error(`expected one ${id}, found ${found.length}`); + return found[0]; +} + +export function byLabel(node: HostNode, label: string | RegExp): HostNode[] { + return findAll(node, (n) => typeof n.props.accessibilityLabel === 'string' + && (typeof label === 'string' ? n.props.accessibilityLabel === label : label.test(n.props.accessibilityLabel))); +} + +/** The text a node shows, its strings joined, Unicode isolates removed. */ +export function textOf(node: HostNode | string): string { + const raw = typeof node === 'string' ? node : node.children.map(textOf).join(''); + return raw.replace(/[⁦-⁩]/g, ''); +} + +/** The text with its isolates, to check direction marks. */ +export function rawTextOf(node: HostNode | string): string { + return typeof node === 'string' ? node : node.children.map(rawTextOf).join(''); +} + +/** Flattened style of a host node. */ +export function styleOf(node: HostNode, state: { pressed?: boolean } = {}): Record { + const flatten = (style: unknown): Record => { + if (!style) return {}; + if (typeof style === 'function') return flatten(style({ pressed: !!state.pressed })); + if (Array.isArray(style)) return Object.assign({}, ...style.map(flatten)); + return style as Record; + }; + return flatten(node.props.style); +} + +// --------------------------------------------------------------------------- +// hooks + +function depsChanged(prev: unknown[] | undefined, next: unknown[] | undefined): boolean { + if (!prev || !next) return true; + return prev.length !== next.length || prev.some((value, i) => !Object.is(value, next[i])); +} + +export interface HookResult { + readonly current: T; + rerender: () => void; + unmount: () => void; + renders: () => number; +} + +/** + * Run `hook` like a component would: state survives renders, a state change + * or store update renders again, effects run after each render and clean up + * on deps change and unmount. + */ +export function renderHook(hook: () => T): HookResult { + const slots: any[] = []; + let index = 0; + let result: T; + let rendering = false; + let dirty = false; + let mounted = true; + let count = 0; + const pending: Array<() => void> = []; + + const schedule = () => { + if (!mounted) return; + if (rendering) { + dirty = true; + return; + } + run(); + }; + + const effect = (fn: () => void | (() => void), deps?: unknown[]) => { + const i = index++; + const prev = slots[i] as { deps?: unknown[]; cleanup?: () => void } | undefined; + if (prev && deps && !depsChanged(prev.deps, deps)) return; + pending.push(() => { + prev?.cleanup?.(); + const cleanup = fn(); + slots[i] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined }; + }); + if (!prev) slots[i] = { deps: undefined }; + }; + + const dispatcher = { + useState: (init: unknown) => { + const i = index++; + if (!(i in slots)) slots[i] = { value: typeof init === 'function' ? (init as () => unknown)() : init }; + const slot = slots[i]; + if (!slot.set) { + slot.set = (value: unknown) => { + const next = typeof value === 'function' ? (value as (v: unknown) => unknown)(slot.value) : value; + if (Object.is(next, slot.value)) return; + slot.value = next; + schedule(); + }; + } + return [slot.value, slot.set]; + }, + useReducer: (reducer: (s: unknown, a: unknown) => unknown, init: unknown, fn?: (a: unknown) => unknown) => { + const i = index++; + if (!(i in slots)) slots[i] = { value: fn ? fn(init) : init }; + const slot = slots[i]; + slot.dispatch ??= (action: unknown) => { + const next = reducer(slot.value, action); + if (Object.is(next, slot.value)) return; + slot.value = next; + schedule(); + }; + return [slot.value, slot.dispatch]; + }, + useMemo: (fn: () => unknown, deps?: unknown[]) => { + const i = index++; + const prev = slots[i]; + if (prev && deps && !depsChanged(prev.deps, deps)) return prev.value; + const value = fn(); + slots[i] = { deps, value }; + return value; + }, + useCallback: (fn: unknown, deps?: unknown[]) => dispatcher.useMemo(() => fn, deps), + useRef: (current: unknown) => { + const i = index++; + if (!(i in slots)) slots[i] = { current }; + return slots[i]; + }, + useEffect: effect, + useLayoutEffect: effect, + useInsertionEffect: effect, + useImperativeHandle: () => { index++; }, + useContext: (context: { _currentValue: unknown }) => context._currentValue, + useSyncExternalStore: (subscribe: (cb: () => void) => () => void, getSnapshot: () => unknown) => { + const i = index++; + const value = getSnapshot(); + if (!slots[i]) { + const slot = { value, unsubscribe: () => undefined as void }; + slots[i] = slot; + slot.unsubscribe = subscribe(() => { + const next = getSnapshot(); + if (Object.is(next, slot.value)) return; + slot.value = next; + schedule(); + }); + } else { + slots[i].value = value; + } + return value; + }, + useDebugValue: () => undefined, + useId: () => `:r${index++}:`, + useTransition: () => [false, (fn: () => void) => fn()], + useDeferredValue: (value: unknown) => value, + }; + + function run() { + let loops = 0; + do { + if (++loops > 50) throw new Error('renderHook: too many re-renders (a selector that returns a new object?)'); + dirty = false; + rendering = true; + index = 0; + const previous = internals.H; + internals.H = dispatcher; + try { + result = hook(); + count++; + } finally { + internals.H = previous; + rendering = false; + } + while (pending.length > 0) { + rendering = true; + try { + pending.shift()!(); + } finally { + rendering = false; + } + } + } while (dirty); + } + + run(); + return { + get current() { return result; }, + rerender: () => run(), + unmount: () => { + mounted = false; + for (const slot of slots) { + slot?.cleanup?.(); + slot?.unsubscribe?.(); + } + }, + renders: () => count, + }; +} + +/** Wait until `check` passes (it throws until then), flushing promises and timers in between. */ +export async function waitFor(check: () => void, { timeout = 2000 } = {}): Promise { + // performance.now: tests fake Date. + const started = performance.now(); + let last: unknown; + for (;;) { + try { + check(); + return; + } catch (err) { + last = err; + } + if (performance.now() - started > timeout) throw last; + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +/** The react-native surface the cards use, as host components. */ +export function reactNativeMock() { + const host = (name: string) => name; + return { + View: host('View'), + Text: host('Text'), + Pressable: host('Pressable'), + Image: host('Image'), + ActivityIndicator: host('ActivityIndicator'), + ScrollView: host('ScrollView'), + Modal: host('Modal'), + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1, flatten: (s: unknown) => s }, + Platform: { OS: 'android', Version: 33, select: (spec: { default?: T; android?: T; ios?: T }) => spec.android ?? spec.default ?? spec.ios }, + I18nManager: { isRTL: false, allowRTL: () => undefined, forceRTL: () => undefined }, + Alert: { alert: vi.fn() }, + Linking: { openURL: vi.fn(async () => undefined) }, + Appearance: { getColorScheme: () => 'light', addChangeListener: () => ({ remove: () => undefined }) }, + AppState: { currentState: 'active', addEventListener: () => ({ remove: () => undefined }) }, + useColorScheme: () => 'light', + useWindowDimensions: () => ({ width: 400, height: 800, scale: 2, fontScale: 1 }), + NativeModules: {}, + NativeEventEmitter: class { addListener() { return { remove: () => undefined }; } removeAllListeners() {} }, + }; +} diff --git a/src/components/email/structured-mail/__tests__/structured-mail-banner.test.ts b/src/components/email/structured-mail/__tests__/structured-mail-banner.test.ts new file mode 100644 index 00000000..20df5972 --- /dev/null +++ b/src/components/email/structured-mail/__tests__/structured-mail-banner.test.ts @@ -0,0 +1,77 @@ +// The one-line mount for MessageContent: cards for a verified sender's +// booking or order mail, nothing for anything else, and product images only +// when the body's remote content would load. +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native', async () => (await import('./render-native')).reactNativeMock()); +vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' })); +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn(async () => true) })); +// The calendar row has its own tests (use-booking-calendar.test.ts); here only +// whether each booking gets one. +vi.mock('../booking-calendar-actions', async () => { + const { createElement: h } = await import('react'); + return { BookingCalendarActions: ({ booking }: { booking: { kind: string } }) => h('Text', { testID: 'calendar-actions' }, booking.kind) }; +}); + +import type { Email } from '../../../../api/types'; +import { useSettingsStore } from '../../../../stores/settings-store'; +import { StructuredMailBanner } from '../StructuredMailBanner'; +import { byTestId, render, textOf } from './render-native'; + +const ORDER = { + '@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop', + orderedItem: { '@type': 'Product', name: 'Boots', image: 'https://img.shop.example/boots.jpg' }, +}; +const PASS = 'mx.example.org; dkim=pass header.d=shop.example; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example'; +const RECEIVED = 'from mail.shop.example (mail.shop.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1'; + +function mail(ar: string | null, subject = 'Your order'): Email { + return { + id: 'm1', threadId: 't', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: false, + receivedAt: '2026-10-30T09:00:00Z', subject, from: [{ email: 'orders@shop.example' }], + headers: [{ name: 'Received', value: RECEIVED }, ...(ar ? [{ name: 'Authentication-Results', value: ar }] : [])], + htmlBody: [{ partId: '1', type: 'text/html' }], + bodyValues: { '1': { value: `

Thanks

` } }, + }; +} + +const initialSettings = useSettingsStore.getState(); +afterEach(() => useSettingsStore.setState(initialSettings, true)); + +describe('StructuredMailBanner', () => { + it('shows the order card of a verified mail', () => { + const tree = render(createElement(StructuredMailBanner, { email: mail(PASS) })); + const [card] = byTestId(tree, 'structured-mail-card-shipment'); + expect(textOf(card)).toContain('Order number'); + expect(textOf(card)).toContain('SO-1'); + }); + + it('shows nothing for an unverified sender or a forward', () => { + expect(render(createElement(StructuredMailBanner, { email: mail(null) })).children).toEqual([]); + expect(render(createElement(StructuredMailBanner, { email: mail(PASS, 'Fwd: Your order') })).children).toEqual([]); + }); + + it('gives a booking card the calendar row, and an order none', () => { + const train = { + '@context': 'http://schema.org', '@type': 'TrainReservation', reservationNumber: 'ZUG1', + reservationFor: { + '@type': 'TrainTrip', trainNumber: 'ICE 1', departureStation: { name: 'Hamburg Hbf' }, departureTime: '2026-12-05T08:46:00+01:00', + arrivalStation: { name: 'Berlin Hbf' }, arrivalTime: '2026-12-05T10:50:00+01:00', + }, + }; + const email = mail(PASS); + email.bodyValues = { '1': { value: `` } }; + expect(byTestId(render(createElement(StructuredMailBanner, { email })), 'calendar-actions').map(textOf)).toEqual(['train']); + expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'calendar-actions')).toHaveLength(0); + }); + + it('loads product images only when remote content would load', () => { + useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: [] }); + expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0); + useSettingsStore.setState({ trustedSenders: ['orders@shop.example'] }); + expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(1); + useSettingsStore.setState({ externalContentPolicy: 'allow', trustedSenders: [] }); + expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(1); + }); +}); diff --git a/src/components/email/structured-mail/__tests__/structured-mail-cards.test.ts b/src/components/email/structured-mail/__tests__/structured-mail-cards.test.ts new file mode 100644 index 00000000..4ed9a366 --- /dev/null +++ b/src/components/email/structured-mail/__tests__/structured-mail-cards.test.ts @@ -0,0 +1,329 @@ +// Port of webmail components/email/structured-mail/__tests__/ +// structured-mail-cards.test.tsx at 972e5f621, on the app's views: the same +// cards from the reviewed fixtures, read back from the rendered tree. The +// strings are the English fallbacks (the vendored catalog predates these +// keys), so the assertions read like the cards do. +import { createElement } from 'react'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native', async () => (await import('./render-native')).reactNativeMock()); +vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' })); +const setStringAsync = vi.fn(async (_value: string) => true); +vi.mock('expo-clipboard', () => ({ setStringAsync: (value: string) => setStringAsync(value) })); +const toastSuccess = vi.fn(); +vi.mock('../../../../stores/toast-store', () => ({ toast: { success: (m: string) => toastSuccess(m), error: vi.fn() } })); +const openExternalUrl = vi.fn(async (_url: string) => true); +vi.mock('../../../../lib/open-url', () => ({ openExternalUrl: (url: string) => openExternalUrl(url) })); + +import { I18nManager } from 'react-native'; +import expectedCards from '../../../../lib/structured-mail/__tests__/fixtures/expected-cards.json'; +import type { Booking, Shipment, StructuredCard, TransportBooking } from '../../../../lib/structured-mail/types'; +import { useSettingsStore } from '../../../../stores/settings-store'; +import { StructuredMailCards, type StructuredMailCardsProps } from '../structured-mail-cards'; +import { byLabel, byTestId, findAll, oneByTestId, rawTextOf, render, styleOf, textOf, type HostNode } from './render-native'; + +const fixtures = expectedCards as unknown as Record; +const card = (id: string, index = 0) => structuredClone(fixtures[id][index]) as T; +const initialSettings = useSettingsStore.getState(); + +beforeAll(() => { + // Only Date is faked: "now" decides whether a year is shown. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-30T12:00:00Z')); +}); +afterAll(() => { + vi.useRealTimers(); +}); +beforeEach(() => { + useSettingsStore.setState({ timeFormat: '24h', calendarTimeZone: 'Europe/Berlin' }); +}); +afterEach(() => { + useSettingsStore.setState(initialSettings, true); + setStringAsync.mockClear(); + toastSuccess.mockClear(); + openExternalUrl.mockClear(); + (I18nManager as { isRTL: boolean }).isRTL = false; +}); + +function show(cards: StructuredCard[], extra: Partial = {}): HostNode { + return render(createElement(StructuredMailCards, { cards, allowRemoteImages: false, ...extra })); +} + +/** The card with this accessible name (one of them). */ +function region(tree: HostNode, name: string, index = 0): HostNode { + const found = findAll(tree, (n) => n.type === 'View' && n.props.accessibilityLabel === name && /^structured-mail-card-/.test(n.props.testID ?? '')); + if (!found[index]) throw new Error(`no card named ${name}`); + return found[index]; +} + +const title = (node: HostNode) => oneByTestId(node, 'structured-mail-title'); +const pressables = (node: HostNode) => findAll(node, (n) => n.type === 'Pressable'); + +describe('StructuredMailCards', () => { + it('renders nothing for an empty list', () => { + expect(show([]).children).toEqual([]); + }); + + it('shows a flight with its route, legs at local time, passengers and seats', () => { + const r = region(show([card('flight-two-passengers-two-legs')]), 'Flight booking'); + expect(title(r).props.accessibilityLabel).toBe('FRA to JFK to FRA'); + expect(textOf(title(r))).toBe('FRA → JFK → FRA'); + expect(textOf(r)).toContain('Flight · Lufthansa'); + const legs = byTestId(r, 'structured-mail-leg'); + expect(legs).toHaveLength(2); + // The mail's wall clock at each airport: 10:15 in Frankfurt, 13:05 in New York. + expect(textOf(legs[0])).toContain('Lufthansa LH 400'); + expect(textOf(legs[0])).toContain('Tue, Dec 1, 10:15 · Frankfurt Airport (FRA)'); + expect(textOf(legs[0])).toContain('13:05 local time · John F. Kennedy International Airport (JFK)'); + // Overnight return: the arrival date is written out. + expect(textOf(legs[1])).toContain('Tue, Dec 8, 17:30'); + expect(textOf(legs[1])).toContain('Wed, Dec 9, 07:20 · Frankfurt Airport (FRA)'); + expect(textOf(legs[0])).toContain('Seats: Erika Mustermann 12A, Max Mustermann 12B'); + expect(textOf(r)).toContain('Erika Mustermann, Max Mustermann'); + }); + + it('marks only the times whose place keeps another clock than the reader', () => { + const [outbound] = byTestId(show([card('flight-two-passengers-two-legs')]), 'structured-mail-leg'); + // Reader in Berlin: Frankfurt needs no mark, New York does. + expect(byTestId(outbound, 'local-time-hint')).toHaveLength(1); + }); + + it('never shifts the time to the reader zone', () => { + useSettingsStore.setState({ calendarTimeZone: 'Asia/Tokyo' }); + const [outbound] = byTestId(show([card('flight-two-passengers-two-legs')]), 'structured-mail-leg'); + expect(textOf(outbound)).toContain('Tue, Dec 1, 10:15'); + expect(textOf(outbound)).toContain('13:05'); + expect(byTestId(outbound, 'local-time-hint')).toHaveLength(2); + }); + + it('writes times on the 12-hour clock when the user picked it', () => { + useSettingsStore.setState({ timeFormat: '12h' }); + const [outbound] = byTestId(show([card('flight-two-passengers-two-legs')]), 'structured-mail-leg'); + expect(textOf(outbound)).toMatch(/Tue, Dec 1, 10:15\sAM/); + }); + + it('copies the booking reference', async () => { + const tree = show([card('flight-two-passengers-two-legs')]); + const [chip] = byLabel(tree, 'Copy booking reference QX7K2M'); + expect(chip.type).toBe('Pressable'); + chip.props.onPress(); + await vi.waitFor(() => expect(setStringAsync).toHaveBeenCalledWith('QX7K2M')); + await vi.waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Booking reference copied')); + }); + + it('shows the check-in link with its host and opens it through the URL guard', () => { + const tree = show([card('flight-two-passengers-two-legs')]); + const [link] = byTestId(tree, 'structured-mail-link'); + expect(link.props.accessibilityRole).toBe('link'); + expect(textOf(link)).toBe('Check in· airline-a.example.com'); + link.props.onPress(); + expect(openExternalUrl).toHaveBeenCalledWith('https://www.airline-a.example.com/checkin?pnr=QX7K2M'); + }); + + it('never renders a link that is not http(s)', () => { + const flight = card('flight-two-passengers-two-legs'); + flight.links = { checkin: { url: 'javascript:alert(1)', host: 'evil.example' }, manage: { url: 'data:text/html,x', host: 'x' } }; + const tree = show([flight]); + expect(byTestId(tree, 'structured-mail-link')).toHaveLength(0); + expect(JSON.stringify(textOf(tree))).not.toContain('javascript:'); + }); + + it('shows a cancelled booking struck through with a Cancelled pill', () => { + const r = region(show([card('flight-cancelled')]), 'Flight booking'); + expect(byTestId(r, 'structured-mail-pill').map(textOf)).toEqual(['Cancelled']); + expect(styleOf(title(r)).textDecorationLine).toBe('line-through'); + }); + + it('marks a single cancelled leg of a live booking', () => { + const flight = card('flight-two-passengers-two-legs'); + flight.legs[1].status = 'cancelled'; + const tree = show([flight]); + expect(byTestId(tree, 'structured-mail-leg')).toHaveLength(1); + const [cancelled] = byTestId(tree, 'structured-mail-leg-cancelled'); + expect(byTestId(cancelled, 'structured-mail-pill').map(textOf)).toEqual(['Cancelled']); + expect(styleOf(title(tree)).textDecorationLine).toBeUndefined(); + }); + + it('shows Pending and On hold', () => { + const pending = card('train-multiple-types'); + pending.status = 'pending'; + const hold = card('graph-hotel'); + hold.status = 'hold'; + const tree = show([pending, hold]); + expect(textOf(region(tree, 'Train booking'))).toContain('Pending'); + expect(textOf(region(tree, 'Hotel booking'))).toContain('On hold'); + }); + + it('titles a train with its towns and lists the stations', () => { + const r = region(show([card('train-multiple-types')]), 'Train booking'); + expect(title(r).props.accessibilityLabel).toBe('Hamburg to München'); + expect(textOf(r)).toContain('Wed, Nov 18, 08:01 · Hamburg Hbf'); + expect(textOf(r)).toContain('13:58 · München Hbf'); + expect(textOf(r)).toContain('Rail Co ICE 577'); + }); + + it('shows a hotel stay with address, check-in and check-out times, nights and the manage link', () => { + const r = region(show([card('graph-hotel')]), 'Hotel booking'); + expect(textOf(title(r))).toBe('Hotel Am Markt'); + expect(textOf(r)).toContain('Marktplatz 1, 10178 Berlin'); + expect(textOf(r)).toContain('Check-in Fri, Dec 4, from 15:00'); + expect(textOf(r)).toContain('Check-out Sun, Dec 6, until 11:00'); + expect(textOf(r)).toContain('2 nights'); + expect(textOf(byLabel(r, /^Manage booking, /)[0])).toContain('hotel-booking.example.com'); + }); + + it('shows a dates-only stay without times', () => { + const r = region(show([card('hotel-dates-only')]), 'Hotel booking'); + expect(textOf(r)).toContain('Check-in Tue, Jun 1, 2027'); + expect(textOf(r)).toContain('Check-out Tue, Jun 8, 2027'); + expect(textOf(r)).not.toContain('from '); + expect(textOf(r)).toContain('7 nights'); + }); + + it('shows a rental car with pick-up, return and car', () => { + const r = region(show([card('rental-car')]), 'Rental car booking'); + expect(textOf(title(r))).toBe('Car Rental Co'); + expect(textOf(r)).toContain('Pick-up Fri, Dec 4, 09:00 · Flughafen BER, Schönefeld'); + expect(textOf(r)).toContain('Return Mon, Dec 7, 18:00 · Flughafen BER, Schönefeld'); + expect(textOf(r)).toContain('VW Golf'); + }); + + it('shows an event with its times, venue, tickets and ticket link', () => { + const tree = show([card('eventbrite-trailing-commas'), card('ahoi-event')]); + const eventbrite = region(tree, 'Event booking', 0); + const workshop = region(tree, 'Event booking', 1); + expect(textOf(title(eventbrite))).toBe('Jazz im Park'); + expect(textOf(eventbrite)).toContain('Sat, Nov 14, 19:00 – 22:30'); + expect(textOf(eventbrite)).toContain('Stadtpark, Parkweg 2, Hamburg'); + expect(textOf(byLabel(eventbrite, /^Ticket, /)[0])).toContain('tickets.example.com'); + expect(textOf(workshop)).toContain('Sun, Jul 18, 2021, 14:00'); + expect(textOf(workshop)).toContain('Tickets: AB12CD-1'); + }); + + it('shows a restaurant table with time, address and party size', () => { + const r = region(show([card('restaurant-cafe')]), 'Restaurant reservation'); + expect(textOf(title(r))).toBe('Café Kranzler'); + expect(textOf(r)).toContain('Sat, Nov 7, 19:30'); + expect(textOf(r)).toContain('Kurfürstendamm 18, 10719 Berlin'); + expect(textOf(r)).toContain('Table for 4'); + }); + + it('shows an order with its parcel: number, status, items, total, tracking and links', async () => { + const r = region(show([card('order-and-parcel')]), 'Order and delivery'); + expect(textOf(title(r))).toBe('Outdoor Shop'); + expect(textOf(r)).toContain('Order · DHL'); + expect(byTestId(r, 'structured-mail-pill').map(textOf)).toEqual(['Shipped']); + expect(textOf(r)).toContain('Wanderschuhe, Socken'); + expect(textOf(oneByTestId(r, 'structured-mail-summary'))).toBe('3 items · Total €129.90'); + expect(textOf(r)).toContain('Arrives by Tue, Nov 3'); + expect(textOf(byLabel(r, /^Track, /)[0])).toBe('Track· dhl.de'); + expect(textOf(byLabel(r, /^View order, /)[0])).toContain('outdoor-shop.example.com'); + + byLabel(r, 'Copy tracking number 00340434161234567890')[0].props.onPress(); + await vi.waitFor(() => expect(setStringAsync).toHaveBeenCalledWith('00340434161234567890')); + byLabel(r, 'Copy order number SO-449120')[0].props.onPress(); + await vi.waitFor(() => expect(setStringAsync).toHaveBeenCalledWith('SO-449120')); + }); + + it('drops the expected date once the order is delivered', () => { + const shipment = card('order-and-parcel'); + shipment.status = 'delivered'; + const r = region(show([shipment]), 'Order and delivery'); + expect(textOf(r)).toContain('Delivered'); + expect(textOf(r)).not.toContain('Arrives by'); + // Carrier and tracking number stay. + expect(textOf(r)).toContain('00340434161234567890'); + }); + + describe('right-to-left place names and values', () => { + const hebrewTrain = (): TransportBooking => { + const train = card('train-multiple-types'); + train.legs[0].departure.place = { name: 'תל אביב סבידור מרכז', address: { locality: 'תל אביב', country: 'IL' } }; + train.legs[0].arrival!.place = { name: 'חיפה מרכז השמונה', address: { locality: 'חיפה', country: 'IL' } }; + return train; + }; + const FSI = String.fromCharCode(0x2068); + const LRI = String.fromCharCode(0x2066); + const PDI = String.fromCharCode(0x2069); + + it.each([false, true])('isolates each stop and points the arrow the reading direction (RTL %s)', (rtl) => { + (I18nManager as { isRTL: boolean }).isRTL = rtl; + const r = region(show([hebrewTrain()]), 'Train booking'); + // Spoken in reading order, whatever the script. + expect(title(r).props.accessibilityLabel).toBe('תל אביב to חיפה'); + const arrow = rtl ? '←' : '→'; + expect(rawTextOf(title(r))).toBe(`${FSI}תל אביב${PDI} ${arrow} ${FSI}חיפה${PDI}`); + // Station names in the leg rows are isolated too. + const leg = oneByTestId(r, 'structured-mail-leg'); + expect(rawTextOf(leg)).toContain(`${FSI}תל אביב סבידור מרכז${PDI}`); + expect(rawTextOf(leg)).toContain(`${FSI}חיפה מרכז השמונה${PDI}`); + }); + + it('keeps references, tracking and order numbers, seats and tickets left to right', () => { + (I18nManager as { isRTL: boolean }).isRTL = true; + const tree = show([card('flight-two-passengers-two-legs'), card('ahoi-event'), card('order-and-parcel')]); + const all = rawTextOf(tree); + for (const value of ['QX7K2M', 'AB12CD', 'SO-449120', '00340434161234567890']) { + expect(all).toContain(`${LRI}${value}${PDI}`); + } + const seats = byTestId(tree, 'structured-mail-seats').map(rawTextOf).join(' '); + for (const seat of ['12A', '12B', '31K', '31J']) expect(seats).toContain(`${LRI}${seat}${PDI}`); + expect(rawTextOf(oneByTestId(tree, 'structured-mail-tickets'))).toContain(`${LRI}AB12CD-1${PDI}`); + }); + }); + + it('shows no action row when a card has nothing it would put there', () => { + const restaurant = card('restaurant-cafe'); + delete restaurant.reference; + // A restaurant card shows no ticket link, so this alone gives no row. + restaurant.links = { ticket: { url: 'https://tables.example.com/t/1', host: 'tables.example.com' } }; + const r = region(show([restaurant]), 'Restaurant reservation'); + expect(byTestId(r, 'structured-mail-actions')).toHaveLength(0); + expect(byTestId(r, 'structured-mail-link')).toHaveLength(0); + }); + + it('shows one Track button per tracking page', () => { + const shipment = card('order-and-parcel'); + const parcel = shipment.parcels[0]; + // The same parcel twice (same number and page), and a second one with its own page. + shipment.parcels = [parcel, { ...parcel }, { ...parcel, trackingNumber: '00340434169999999999', trackingUrl: { url: 'https://www.dhl.de/x?piececode=00340434169999999999', host: 'dhl.de' } }]; + const r = region(show([shipment]), 'Order and delivery'); + const tracks = byLabel(r, /^Track, /); + tracks.forEach((track) => track.props.onPress()); + expect(openExternalUrl.mock.calls.map(([url]) => url)).toEqual([parcel.trackingUrl!.url, 'https://www.dhl.de/x?piececode=00340434169999999999']); + }); + + it('lists three items and says how many more there are', () => { + const shipment: Shipment = { + kind: 'shipment', key: 's', merchant: 'Shop', orderNumber: 'A-1', source: 'json-ld', parcels: [], + items: ['One', 'Two', 'Three', 'Four', 'Five'].map((name) => ({ name })), + total: { amount: '1299.00', currency: 'USD' }, + }; + const r = region(show([shipment]), 'Order and delivery'); + expect(textOf(r)).toContain('One, Two, Three and 2 more'); + expect(textOf(r)).not.toContain('Four'); + expect(textOf(r)).toContain('Total $1,299.00'); + }); + + it('loads item images only when remote content is allowed', () => { + expect(byTestId(show([card('order-and-parcel')]), 'structured-mail-item-image')).toHaveLength(0); + const images = byTestId(show([card('order-and-parcel')], { allowRemoteImages: true }), 'structured-mail-item-image'); + expect(images).toHaveLength(1); + expect(images[0].props.source).toEqual({ uri: 'https://img.shop.example.com/p/1.jpg' }); + }); + + it('gives each booking to the calendar slot, and none for a shipment', () => { + const slot = vi.fn((booking: Booking) => createElement('Text', { testID: 'slot' }, `add ${booking.kind}`)); + const tree = show([card('flight-two-passengers-two-legs'), card('graph-hotel'), card('order-and-parcel')], { calendarActions: slot }); + expect(slot).toHaveBeenCalledTimes(2); + expect(slot.mock.calls.map(([b]) => b.kind)).toEqual(['flight', 'lodging']); + expect(slot.mock.calls[0][0]).toMatchObject({ reference: 'QX7K2M' }); + expect(byTestId(region(tree, 'Flight booking'), 'slot').map(textOf)).toEqual(['add flight']); + }); + + it('renders no calendar items in the action row when the slot is undefined', () => { + const r = region(show([card('restaurant-cafe')]), 'Restaurant reservation'); + // Only the reference chip. + expect(pressables(r)).toHaveLength(1); + }); +}); diff --git a/src/components/email/structured-mail/__tests__/use-booking-calendar.test.ts b/src/components/email/structured-mail/__tests__/use-booking-calendar.test.ts new file mode 100644 index 00000000..073a0438 --- /dev/null +++ b/src/components/email/structured-mail/__tests__/use-booking-calendar.test.ts @@ -0,0 +1,404 @@ +// Port of webmail components/email/structured-mail/__tests__/ +// use-booking-calendar.test.tsx at 3aac5a5c4, against the app's calendar +// store (its write functions swapped for mocks) and api/calendar's +// findEventsByUid (mocked). The webmail's plugin "blocked" cases and its +// keyboard and viewport cases for the floating picker have no app +// counterpart; the account switch cases are the app's own. +import { createElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native', async () => (await import('./render-native')).reactNativeMock()); +vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' })); +const findEventsByUid = vi.fn(); +vi.mock('../../../../api/calendar', async (importOriginal) => ({ + ...(await importOriginal()), + findEventsByUid: (uid: string) => findEventsByUid(uid), +})); +const navigate = vi.fn(); +vi.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate }) })); + +import type { Calendar, CalendarEvent, Email } from '../../../../api/types'; +import { loadAirportZones, readStructuredMail, resolveCards, type Booking, type StructuredCard } from '../../../../lib/structured-mail'; +import { bookingToEvents } from '../../../../lib/structured-mail/calendar'; +import { usePendingCalendarOpen } from '../../../../navigation/pending-calendar-open'; +import { CalendarAccountChangedError, useCalendarStore } from '../../../../stores/calendar-store'; +import { useCalendarSubscriptionsStore } from '../../../../stores/calendar-subscriptions-store'; +import { BookingCalendarActions } from '../booking-calendar-actions'; +import { resetRemovedBookingUids, useBookingCalendar } from '../use-booking-calendar'; +import { byTestId, findAll, render, renderHook, textOf, waitFor, type HostNode } from './render-native'; + +const ctx = 'http://schema.org'; +const NOW = new Date('2026-11-01T12:00:00Z'); +const EMAIL = { from: [{ email: 'booking@mail.lufthansa.com' }], subject: 'Your booking QX7K2M' } as unknown as Email; + +function leg(number: string, dep: [string, string], arr: [string, string], status?: string) { + return { + '@context': ctx, '@type': 'FlightReservation', reservationNumber: 'QX7K2M', underName: 'Erika Mustermann', + ...(status ? { reservationStatus: `http://schema.org/${status}` } : {}), + reservationFor: { + '@type': 'Flight', flightNumber: number, airline: { name: 'Lufthansa', iataCode: 'LH' }, + departureAirport: { iataCode: dep[0] }, departureTime: dep[1], + arrivalAirport: { iataCode: arr[0] }, arrivalTime: arr[1], + }, + }; +} + +const OUT = leg('400', ['FRA', '2026-12-01T10:15:00+01:00'], ['JFK', '2026-12-01T13:05:00-05:00']); +const OUT_MOVED = leg('400', ['FRA', '2026-12-01T12:40:00+01:00'], ['JFK', '2026-12-01T15:35:00-05:00']); +const BACK = leg('401', ['JFK', '2026-12-08T17:30:00-05:00'], ['FRA', '2026-12-09T07:20:00+01:00']); +const BACK_CANCELLED = leg('401', ['JFK', '2026-12-08T17:30:00-05:00'], ['FRA', '2026-12-09T07:20:00+01:00'], 'ReservationCancelled'); +const OUT_CANCELLED = leg('400', ['FRA', '2026-12-01T10:15:00+01:00'], ['JFK', '2026-12-01T13:05:00-05:00'], 'ReservationCancelled'); + +async function bookingFrom(items: unknown[], mailDate = '2026-10-30T10:00:00Z'): Promise { + const html = `${items.map((i) => ``).join('')}`; + const cards = resolveCards(readStructuredMail(html, { mailDate: new Date(mailDate) }), await loadAirportZones()); + expect(cards).toHaveLength(1); + return cards[0] as Booking; +} + +/** CalendarEvent/get's shape for an event made from `wanted`: id, calendar, server defaults. */ +function stored(wanted: Partial, id: string, calendarId = 'cal-default'): CalendarEvent { + const locations = Object.fromEntries(Object.entries(wanted.locations ?? {}).map(([key, loc]) => [key, { ...loc, iCalendar: { name: 'vlocation' } }])); + return { + ...wanted, id, calendarIds: { [calendarId]: true }, utcStart: '2026-12-01T09:15:00Z', participants: null, + description: 'whatever the server keeps', locations, + } as unknown as CalendarEvent; +} + +const rights = (write: boolean) => ({ mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: write, mayWriteOwn: write, mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: write }); +const cal = (id: string, extra: Partial = {}): Calendar => ({ + id, name: id, color: '#3366ff', isDefault: false, isSubscribed: true, isVisible: true, sortOrder: 0, description: null, + shareWith: null, myRights: rights(true), ...extra, +}); + +const createEvent = vi.fn(); +const updateEvent = vi.fn(); +const deleteEvent = vi.fn(); +const initialCalendar = useCalendarStore.getState(); +const initialSubscriptions = useCalendarSubscriptionsStore.getState(); + +/** The calendar holds these events, found by uid. */ +function calendarHolds(events: CalendarEvent[]) { + findEventsByUid.mockImplementation(async (uid: string) => events.filter((e) => e.uid === uid)); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + findEventsByUid.mockReset(); + calendarHolds([]); + resetRemovedBookingUids(); + createEvent.mockReset().mockImplementation(async (event: Partial, calendarId: string) => { + const created = { ...event, id: `new-${event.uid}`, calendarIds: { [calendarId]: true } } as CalendarEvent; + useCalendarStore.setState((s) => ({ events: [...s.events, created] })); + return created; + }); + updateEvent.mockReset().mockImplementation(async (id: string, patch: Partial) => { + useCalendarStore.setState((s) => ({ events: s.events.map((e) => (e.id === id ? { ...e, ...patch } : e)) })); + }); + deleteEvent.mockReset().mockResolvedValue(undefined); + navigate.mockReset(); + useCalendarStore.setState({ + calendars: [cal('cal-default', { isDefault: true })], + events: [], + taskOnlyCalendarIds: [], + createEvent, updateEvent, deleteEvent, + }); + useCalendarSubscriptionsStore.setState({ subscriptions: [] }); +}); + +afterEach(() => { + vi.useRealTimers(); + useCalendarStore.setState(initialCalendar, true); + useCalendarSubscriptionsStore.setState(initialSubscriptions, true); + usePendingCalendarOpen.setState({ target: null }); +}); + +async function settled(card: StructuredCard | null) { + const hook = renderHook(() => useBookingCalendar(card, EMAIL)); + await waitFor(() => expect(hook.current.status).not.toBe('loading')); + return hook; +} + +describe('useBookingCalendar', () => { + it('offers to add a booking the calendar lacks, and adds each live leg once', async () => { + const booking = await bookingFrom([OUT, BACK]); + const hook = await settled(booking); + expect(hook.current.status).toBe('add'); + expect(findEventsByUid).toHaveBeenCalledTimes(2); + + await hook.current.add('cal-default'); + expect(createEvent).toHaveBeenCalledTimes(2); + const [first, calendarId] = createEvent.mock.calls[0]; + const wanted = bookingToEvents(booking, { senderDomain: 'lufthansa.com' })[0]; + expect(calendarId).toBe('cal-default'); + expect(first).toMatchObject({ + uid: wanted.uid, title: 'LH 400 FRA → JFK', + start: '2026-12-01T10:15:00', timeZone: 'Europe/Berlin', duration: 'PT8H50M', + }); + expect(first).not.toHaveProperty('participants'); + // No options: never scheduling messages. + expect(createEvent.mock.calls[0]).toHaveLength(2); + expect(hook.current.status).toBe('in-calendar'); + expect(hook.current.notice).toBe('added'); + }); + + it('says the booking is in the calendar when every leg is there unchanged', async () => { + const booking = await bookingFrom([OUT, BACK]); + const [a, b] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(a, 'e1'), stored(b, 'e2')]); + const hook = await settled(booking); + expect(hook.current.status).toBe('in-calendar'); + expect(useCalendarStore.getState().events.map((e) => e.id).sort()).toEqual(['e1', 'e2']); + }); + + it('updates the found event after a schedule change and never creates a second one', async () => { + const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(old, 'e1')]); + const hook = await settled(await bookingFrom([OUT_MOVED])); + expect(hook.current.status).toBe('update'); + + await hook.current.update(); + expect(createEvent).not.toHaveBeenCalled(); + expect(updateEvent).toHaveBeenCalledTimes(1); + const [id, patch, options] = updateEvent.mock.calls[0]; + expect(id).toBe('e1'); + expect(patch).toMatchObject({ start: '2026-12-01T12:40:00', timeZone: 'Europe/Berlin', duration: 'PT8H55M' }); + // The stored description isn't the generated one any more: title and notes stay the user's. + expect(patch).not.toHaveProperty('title'); + expect(patch).not.toHaveProperty('description'); + expect(options).toBeUndefined(); + expect(hook.current.status).toBe('in-calendar'); + }); + + it('adds a missing leg next to the others, in their calendar, as an update', async () => { + useCalendarStore.setState({ calendars: [cal('cal-default', { isDefault: true }), cal('cal-travel')] }); + const booking = await bookingFrom([OUT, BACK]); + const [a] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(a, 'e1', 'cal-travel')]); + const hook = await settled(booking); + expect(hook.current.status).toBe('update'); + await hook.current.update(); + expect(createEvent).toHaveBeenCalledTimes(1); + expect(createEvent.mock.calls[0][0]).toMatchObject({ title: 'LH 401 JFK → FRA' }); + expect(createEvent.mock.calls[0][1]).toBe('cal-travel'); + expect(updateEvent).not.toHaveBeenCalled(); + }); + + it('offers to remove a cancelled booking that is in the calendar, and removes it', async () => { + const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(old, 'e1')]); + const hook = await settled(await bookingFrom([OUT_CANCELLED])); + expect(hook.current.status).toBe('remove'); + await hook.current.remove(); + expect(deleteEvent).toHaveBeenCalledWith('e1'); + expect(createEvent).not.toHaveBeenCalled(); + expect(hook.current.status).toBe('cancelled'); + expect(hook.current.notice).toBe('removed'); + }); + + it('offers nothing for a cancelled booking the calendar never had', async () => { + const hook = await settled(await bookingFrom([OUT_CANCELLED])); + expect(hook.current.status).toBe('cancelled'); + }); + + it('never adds a cancelled leg, and removes its event along with the update', async () => { + const booking = await bookingFrom([OUT, BACK_CANCELLED]); + const hook = await settled(booking); + expect(hook.current.status).toBe('add'); + await hook.current.add(); + expect(createEvent).toHaveBeenCalledTimes(1); + expect(createEvent.mock.calls[0][0]).toMatchObject({ title: 'LH 400 FRA → JFK' }); + + const [a, b] = bookingToEvents(await bookingFrom([OUT, BACK]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(a, 'e1'), stored(b, 'e2')]); + useCalendarStore.setState({ events: [] }); + createEvent.mockClear(); + const second = await settled(booking); + expect(second.current.status).toBe('update'); + await second.current.update(); + expect(deleteEvent).toHaveBeenCalledWith('e2'); + expect(createEvent).not.toHaveBeenCalled(); + expect(updateEvent).not.toHaveBeenCalled(); + }); + + it('does not find events another sender domain made', async () => { + const booking = await bookingFrom([OUT]); + const [foreign] = bookingToEvents(booking, { senderDomain: 'lufthansa-deals.com' }); + calendarHolds([stored(foreign, 'e9')]); + const hook = await settled(booking); + expect(hook.current.status).toBe('add'); + }); + + it('shows nothing for a booking that is over', async () => { + vi.setSystemTime(new Date('2026-12-02T12:00:00Z')); + expect(renderHook(() => useBookingCalendar(null, EMAIL)).current.status).toBe('hidden'); + const booking = await bookingFrom([OUT]); + expect(renderHook(() => useBookingCalendar(booking, EMAIL)).current.status).toBe('hidden'); + expect(findEventsByUid).not.toHaveBeenCalled(); + }); + + it('shows nothing for shipments', () => { + const shipment: StructuredCard = { kind: 'shipment', key: 'shipment|order:1', orderNumber: '1', items: [], parcels: [], source: 'json-ld' }; + expect(renderHook(() => useBookingCalendar(shipment, EMAIL)).current.status).toBe('hidden'); + }); + + it('shows nothing when the lookup fails, rather than risk a duplicate', async () => { + findEventsByUid.mockRejectedValue(new Error('offline')); + const hook = await settled(await bookingFrom([OUT])); + expect(hook.current.status).toBe('hidden'); + }); + + it('shows nothing to add without a calendar it may write to', async () => { + useCalendarStore.setState({ calendars: [cal('ro', { myRights: rights(false) })] }); + const hook = await settled(await bookingFrom([OUT])); + expect(hook.current.status).toBe('hidden'); + }); + + it('says why an add failed and looks the events up again', async () => { + createEvent.mockRejectedValue(new Error('Failed to create event')); + const hook = await settled(await bookingFrom([OUT])); + await hook.current.add(); + expect(hook.current.error).toBe("Couldn't change your calendar. Try again."); + expect(hook.current.status).toBe('add'); + await waitFor(() => expect(findEventsByUid).toHaveBeenCalledTimes(2)); + }); + + it('shows a booking added from another card for the same booking as in the calendar', async () => { + const first = await settled(await bookingFrom([OUT])); + const second = await settled(await bookingFrom([OUT])); + expect(first.current.status).toBe('add'); + expect(second.current.status).toBe('add'); + await second.current.add(); + expect(second.current.status).toBe('in-calendar'); + await waitFor(() => expect(first.current.status).toBe('in-calendar')); + expect(createEvent).toHaveBeenCalledTimes(1); + }); + + it('shows a booking removed from another card as gone', async () => { + const [old] = bookingToEvents(await bookingFrom([OUT]), { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(old, 'e1')]); + deleteEvent.mockImplementation(async (id: string) => { + useCalendarStore.setState((s) => ({ events: s.events.filter((e) => e.id !== id) })); + }); + const first = await settled(await bookingFrom([OUT_CANCELLED])); + const second = await settled(await bookingFrom([OUT_CANCELLED])); + expect(first.current.status).toBe('remove'); + await second.current.remove(); + await waitFor(() => expect(first.current.status).toBe('cancelled')); + }); + + describe('account switches', () => { + it('drops a lookup that lands after the account changed', async () => { + let answer: (events: CalendarEvent[]) => void = () => undefined; + findEventsByUid.mockImplementation(() => new Promise((resolve) => { answer = resolve; })); + const hook = renderHook(() => useBookingCalendar(OUT_BOOKING!, EMAIL)); + expect(hook.current.status).toBe('loading'); + useCalendarStore.getState().reset(); + useCalendarStore.setState({ calendars: [cal('cal-default', { isDefault: true })], createEvent, updateEvent, deleteEvent }); + answer([]); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(hook.current.status).toBe('loading'); + }); + + it('stops a multi-leg add at the switch, and says nothing failed', async () => { + const hook = await settled(await bookingFrom([OUT, BACK])); + createEvent.mockImplementationOnce(async (event: Partial, calendarId: string) => { + const created = { ...event, id: 'new-1', calendarIds: { [calendarId]: true } } as CalendarEvent; + // The account changes while the first leg is being written. + useCalendarStore.getState().reset(); + useCalendarStore.setState({ createEvent, updateEvent, deleteEvent }); + return created; + }); + await hook.current.add('cal-default'); + // The second leg never went out to the next account. + expect(createEvent).toHaveBeenCalledTimes(1); + expect(hook.current.error).toBeNull(); + }); + + it('shows no error when the store refuses a write the switch overtook', async () => { + createEvent.mockRejectedValue(new CalendarAccountChangedError()); + const hook = await settled(await bookingFrom([OUT])); + await hook.current.add(); + expect(hook.current.error).toBeNull(); + expect(hook.current.notice).toBeNull(); + }); + }); +}); + +let OUT_BOOKING: Booking | null = null; +beforeEach(async () => { + OUT_BOOKING ??= await bookingFrom([OUT]); +}); + +/** BookingCalendarActions with its own state; the tree is rendered from the latest output. */ +function mountActions(booking: Booking) { + const hook = renderHook(() => BookingCalendarActions({ booking, email: EMAIL })); + return { hook, tree: (): HostNode => render(hook.current ?? createElement('View')) }; +} + +const buttonText = (tree: HostNode) => findAll(tree, (n) => n.type === 'Pressable').map(textOf); + +describe('BookingCalendarActions', () => { + it('lists only writable event calendars of the own account, the default first', async () => { + useCalendarStore.setState({ + calendars: [ + cal('Work'), + cal('Personal', { isDefault: true }), + cal('Tasks'), + cal('Holidays', { myRights: rights(false) }), + cal('Team', { isShared: true, accountId: 'other' }), + cal('Feed'), + ], + taskOnlyCalendarIds: ['Tasks'], + }); + useCalendarSubscriptionsStore.setState({ subscriptions: [{ id: 'sub', calendarId: 'Feed' } as never] }); + const { hook, tree } = mountActions(await bookingFrom([OUT])); + await waitFor(() => expect(byTestId(tree(), 'booking-calendar-add')).toHaveLength(1)); + byTestId(tree(), 'booking-calendar-add')[0].props.onPress(); + const choices = byTestId(tree(), 'booking-calendar-choice'); + expect(choices.map(textOf)).toEqual(['Personal', 'Work']); + choices[1].props.onPress(); + await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1)); + expect(createEvent.mock.calls[0][1]).toBe('Work'); + await waitFor(() => expect(textOf(byTestId(tree(), 'booking-calendar-notice')[0])).toBe('Added to your calendar')); + hook.unmount(); + }); + + it('adds straight away when there is one calendar to choose, then shows the pill and the way to the calendar', async () => { + const booking = await bookingFrom([OUT]); + const { tree } = mountActions(booking); + await waitFor(() => expect(byTestId(tree(), 'booking-calendar-add')).toHaveLength(1)); + byTestId(tree(), 'booking-calendar-add')[0].props.onPress(); + expect(byTestId(tree(), 'booking-calendar-picker')).toHaveLength(0); + await waitFor(() => expect(createEvent).toHaveBeenCalledTimes(1)); + expect(createEvent.mock.calls[0][1]).toBe('cal-default'); + + const [wanted] = bookingToEvents(booking, { senderDomain: 'lufthansa.com' }); + calendarHolds([stored(wanted, 'e1')]); + useCalendarStore.setState({ events: [] }); + const again = mountActions({ ...booking }); + await waitFor(() => expect(textOf(again.tree())).toContain('In your calendar')); + expect(buttonText(again.tree())).toEqual(['View in calendar']); + byTestId(again.tree(), 'booking-calendar-view')[0].props.onPress(); + expect(usePendingCalendarOpen.getState().target).toMatchObject({ kind: 'event', eventId: 'e1', serverId: 'e1', startMs: Date.parse('2026-12-01T09:15:00Z') }); + expect(navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' }); + }); + + it('shows the error under the buttons when a change fails', async () => { + createEvent.mockRejectedValue(new Error('nope')); + const { tree } = mountActions(await bookingFrom([OUT])); + await waitFor(() => expect(byTestId(tree(), 'booking-calendar-add')).toHaveLength(1)); + byTestId(tree(), 'booking-calendar-add')[0].props.onPress(); + await waitFor(() => expect(textOf(byTestId(tree(), 'booking-calendar-error')[0])).toBe("Couldn't change your calendar. Try again.")); + }); + + it('renders nothing for a booking that is over', async () => { + const booking = await bookingFrom([OUT]); + vi.setSystemTime(new Date('2026-12-02T12:00:00Z')); + const { hook } = mountActions(booking); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(hook.current).toBeNull(); + expect(findEventsByUid).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.ts b/src/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.ts new file mode 100644 index 00000000..8119603f --- /dev/null +++ b/src/components/email/structured-mail/__tests__/use-structured-mail-pkpass.test.ts @@ -0,0 +1,163 @@ +// Port of webmail components/email/structured-mail/__tests__/ +// use-structured-mail-pkpass.test.tsx at 66c73d0f4. App: messages carry +// raw headers only, the gate is mocked, and the download goes through the +// JMAP client (mocked) from the account the reader passes. +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const gate = vi.hoisted(() => ({ on: true })); +vi.mock('../../../../lib/structured-mail/gate', () => ({ + isStructuredMailEnabled: () => gate.on, + useStructuredMailEnabled: () => gate.on, +})); +const blobs = vi.hoisted(() => ({ bytes: new Uint8Array() as Uint8Array, urls: [] as string[] })); +vi.mock('../../../../api/jmap-client', () => ({ + BLOB_TIMEOUT_MS: 300_000, + jmapClient: { + getBlobDownloadUrl: (blobId: string, name?: string, type?: string, accountId?: string) => `${accountId}|${blobId}|${name}|${type}`, + authenticatedFetch: async (url: string) => { + blobs.urls.push(url); + const b = blobs.bytes; + return new Response(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer, { status: 200 }); + }, + }, +})); + +import type { Email } from '../../../../api/types'; +import type { TransportBooking } from '../../../../lib/structured-mail'; +import { AIR_SEMANTICS, MAIL_DATE, TRAIN_SEMANTICS, boardingPass, zipPass } from '../../../../lib/structured-mail/__tests__/pkpass-fixtures'; +import { useStructuredMail } from '../use-structured-mail'; +import { renderHook, waitFor } from './render-native'; + +const PASS = 'mx.example.org; dkim=pass header.d=swiss.example; spf=pass smtp.mailfrom=bounce@swiss.example; dmarc=pass header.from=swiss.example'; +const RECEIVED = 'from mail.swiss.example (mail.swiss.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1'; +const PLAIN = '

Your boarding pass is attached.

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

Your boarding pass is attached.

`; + +/** The flight of AIR_SEMANTICS as the booking mail's markup. */ +function flightMarkup(fields: { departureTime?: string; status?: string } = {}) { + const { departureTime = '2026-12-03T07:40:00+01:00', status = 'http://schema.org/ReservationConfirmed' } = fields; + return { + '@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'PK7Q2Z', + reservationStatus: status, underName: { '@type': 'Person', name: 'Jana Beispiel' }, + reservationFor: { + '@type': 'Flight', flightNumber: 'LX1072', airline: { '@type': 'Airline', name: 'Swiss', iataCode: 'LX' }, + departureAirport: { '@type': 'Airport', name: 'Zurich Airport', iataCode: 'ZRH' }, departureTime, + arrivalAirport: { '@type': 'Airport', name: 'Berlin Brandenburg', iataCode: 'BER' }, arrivalTime: '2026-12-03T09:05:00+01:00', + }, + }; +} + +/** The train of TRAIN_SEMANTICS, as the markup writes it ("ICE597", full station names). */ +const TRAIN_MARKUP = { + '@context': 'http://schema.org', '@type': 'TrainReservation', reservationNumber: 'ZUG4471', underName: { '@type': 'Person', name: 'Jana Beispiel' }, + reservationFor: { + '@type': 'TrainTrip', trainNumber: 'ICE597', provider: { '@type': 'Organization', name: 'DB Fernverkehr' }, + departureStation: { '@type': 'TrainStation', name: 'Hamburg Hauptbahnhof' }, departureTime: '2026-12-05T08:46:00+01:00', + arrivalStation: { '@type': 'TrainStation', name: 'Köln Hauptbahnhof' }, arrivalTime: '2026-12-05T12:50:00+01:00', + }, +}; + +function mail(id: string, options: { ar?: string | null; html?: string | null; htmlLoaded?: boolean } = {}): Email { + const { ar = PASS, html = PLAIN, htmlLoaded = true } = options; + return { + id, threadId: 't', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: true, + receivedAt: MAIL_DATE.toISOString(), subject: 'Your boarding pass', + from: [{ email: 'boarding@swiss.example' }], + headers: [{ name: 'Received', value: RECEIVED }, ...(ar ? [{ name: 'Authentication-Results', value: ar }] : [])], + htmlBody: [{ partId: '1', type: html === null ? 'text/plain' : 'text/html' }], + bodyValues: htmlLoaded ? { '1': { value: html ?? 'Your boarding pass is attached.', isTruncated: false } } : {}, + attachments: [{ blobId: 'blob-pass', size: 3000, name: 'BoardingPass.pkpass', type: 'application/vnd.apple.pkpass' }], + }; +} + +async function passBlob(semantics: Record = AIR_SEMANTICS, transitType = 'PKTransitTypeAir') { + blobs.bytes = await zipPass(boardingPass(semantics, transitType)); +} + +/** Let effects and the airport-table import settle when nothing is expected to show. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + +afterEach(() => { + gate.on = true; + blobs.urls = []; +}); + +describe('useStructuredMail with a Wallet pass attached', () => { + it('shows the resolved flight card of a trusted mail without booking markup, and downloads nothing for an untrusted one', async () => { + await passBlob(); + const untrusted = renderHook(() => useStructuredMail(mail('u', { ar: null }), 'acc-1')); + const hook = renderHook(() => useStructuredMail(mail('t'), 'acc-1')); + await waitFor(() => expect(hook.current).toHaveLength(1)); + const [card] = hook.current as TransportBooking[]; + expect(card).toMatchObject({ kind: 'flight', reference: 'PK7Q2Z', source: 'pkpass' }); + expect(card.legs[0].departure.at).toMatchObject({ time: '07:40:00', zone: 'Europe/Zurich' }); + expect(card.legs[0].arrival?.at).toMatchObject({ time: '09:05:00', zone: 'Europe/Berlin' }); + expect(blobs.urls).toEqual(['acc-1|blob-pass|BoardingPass.pkpass|application/vnd.apple.pkpass']); + expect(untrusted.current).toEqual([]); + }); + + it('reads the pass of a plain-text mail too', async () => { + await passBlob(); + const hook = renderHook(() => useStructuredMail(mail('p', { html: null }))); + await waitFor(() => expect(hook.current).toMatchObject([{ kind: 'flight', source: 'pkpass' }])); + }); + + it('downloads nothing and shows nothing with the gate off', async () => { + gate.on = false; + await passBlob(); + const hook = renderHook(() => useStructuredMail(mail('g'))); + await settle(); + expect(hook.current).toEqual([]); + expect(blobs.urls).toEqual([]); + }); + + it('waits for the HTML before asking for passes, as it may hold booking markup', async () => { + await passBlob(); + const hook = renderHook(() => useStructuredMail(mail('w', { htmlLoaded: false }))); + await settle(); + expect(hook.current).toEqual([]); + expect(blobs.urls).toEqual([]); + }); + + describe('when the HTML has booking markup, the markup alone counts and nothing is downloaded', () => { + it('keeps a cancelled markup booking cancelled', async () => { + await passBlob(); + const hook = renderHook(() => useStructuredMail(mail('c', { html: ld(flightMarkup({ status: 'http://schema.org/ReservationCancelled' })) }))); + await waitFor(() => expect(hook.current).toHaveLength(1)); + await settle(); + expect(hook.current).toMatchObject([{ kind: 'flight', status: 'cancelled', source: 'json-ld' }]); + expect(blobs.urls).toEqual([]); + }); + + it('shows nothing when the markup booking was rejected', async () => { + await passBlob(); + const html = ld(flightMarkup(), flightMarkup({ departureTime: '2026-12-03T08:10:00+01:00' })); + const hook = renderHook(() => useStructuredMail(mail('r', { html }))); + await settle(); + expect(hook.current).toEqual([]); + expect(blobs.urls).toEqual([]); + }); + + it('shows one train card with the markup leg when the pass writes number and station differently', async () => { + await passBlob(TRAIN_SEMANTICS, 'PKTransitTypeTrain'); + const hook = renderHook(() => useStructuredMail(mail('i', { html: ld(TRAIN_MARKUP) }))); + await waitFor(() => expect(hook.current).toHaveLength(1)); + await settle(); + const cards = hook.current as TransportBooking[]; + expect(cards).toHaveLength(1); + expect(cards[0].legs).toHaveLength(1); + expect(cards[0].legs[0]).toMatchObject({ number: 'ICE597', departure: { place: { name: 'Hamburg Hauptbahnhof' } } }); + expect(blobs.urls).toEqual([]); + }); + + it('shows one flight card when the pass has no confirmation number', async () => { + const { confirmationNumber: _ref, ...noRef } = AIR_SEMANTICS; + await passBlob(noRef); + const hook = renderHook(() => useStructuredMail(mail('f', { html: ld(flightMarkup()) }))); + await waitFor(() => expect(hook.current).toHaveLength(1)); + await settle(); + expect(hook.current).toMatchObject([{ kind: 'flight', source: 'json-ld', reference: 'PK7Q2Z' }]); + expect(blobs.urls).toEqual([]); + }); + }); +}); diff --git a/src/components/email/structured-mail/__tests__/use-structured-mail.test.ts b/src/components/email/structured-mail/__tests__/use-structured-mail.test.ts new file mode 100644 index 00000000..6404ab93 --- /dev/null +++ b/src/components/email/structured-mail/__tests__/use-structured-mail.test.ts @@ -0,0 +1,81 @@ +// Port of webmail components/email/structured-mail/__tests__/ +// use-structured-mail.test.tsx at ba6d5e448. App: messages carry raw +// headers only, and the gate is the app's (on until an org policy exists), +// switched here through a mock. +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, +})); + +import type { Email } from '../../../../api/types'; +import { CARD_SOURCES } from '../sources'; +import { useStructuredMail } from '../use-structured-mail'; +import { renderHook, waitFor } from './render-native'; + +const ld = (value: unknown) => ``; +const ORDER = { '@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-1', merchant: 'Shop' }; +const FLIGHT = { + '@context': 'http://schema.org', '@type': 'FlightReservation', reservationNumber: 'QX7K2M', underName: 'Erika Mustermann', + reservationFor: { + '@type': 'Flight', flightNumber: '400', airline: { name: 'Lufthansa', iataCode: 'LH' }, + departureAirport: { iataCode: 'FRA' }, departureTime: '2026-12-01T10:15:00+01:00', + arrivalAirport: { iataCode: 'JFK' }, arrivalTime: '2026-12-01T13:05:00-05:00', + }, +}; +const PASS = 'mx.example.org; dkim=pass header.d=shop.example; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example'; +const RECEIVED = 'from mail.shop.example (mail.shop.example [192.0.2.1]) by mx.example.org (Stalwart SMTP) with ESMTPS id 1'; + +function mail(id: string, body: string, ar: string | null = PASS): Email { + return { + id, threadId: 't', mailboxIds: {}, keywords: {}, size: 1, hasAttachment: false, + receivedAt: '2026-10-30T09:00:00Z', subject: 'Booking', + from: [{ email: 'orders@shop.example' }], + headers: [{ name: 'Received', value: RECEIVED }, ...(ar ? [{ name: 'Authentication-Results', value: ar }] : [])], + htmlBody: [{ partId: '1', type: 'text/html' }], + bodyValues: { '1': { value: `${body}`, isTruncated: false } }, + }; +} + +afterEach(() => { + gate.on = true; + CARD_SOURCES.length = 0; +}); + +describe('useStructuredMail', () => { + it('returns the cards of a trusted message', () => { + const hook = renderHook(() => useStructuredMail(mail('a', ld(ORDER)))); + expect(hook.current).toMatchObject([{ kind: 'shipment', orderNumber: 'SO-1' }]); + }); + + it('returns nothing for an untrusted message or with the gate off', () => { + expect(renderHook(() => useStructuredMail(mail('b', ld(ORDER), null))).current).toEqual([]); + gate.on = false; + expect(renderHook(() => useStructuredMail(mail('c', ld(ORDER)))).current).toEqual([]); + }); + + it('shows a flight only once the airport zones are resolved', async () => { + const hook = renderHook(() => useStructuredMail(mail('d', ld(FLIGHT)))); + expect(hook.current).toEqual([]); + await waitFor(() => expect(hook.current).toHaveLength(1)); + expect(hook.current[0]).toMatchObject({ kind: 'flight', legs: [{ departure: { at: { zone: 'Europe/Berlin' } } }] }); + }); + + it('merges cards from attachment sources, and never asks them about untrusted mail', async () => { + let asked = 0; + const accounts: (string | undefined)[] = []; + CARD_SOURCES.push(async ({ accountId }) => { + asked++; + accounts.push(accountId); + return [{ kind: 'shipment', key: 'shipment|order:so-2', orderNumber: 'SO-2', merchant: 'Shop', items: [], parcels: [], source: 'pkpass' }]; + }); + const hook = renderHook(() => useStructuredMail(mail('e', ld(ORDER)), 'shared-acc')); + await waitFor(() => expect(hook.current).toHaveLength(2)); + renderHook(() => useStructuredMail(mail('f', ld(ORDER), null))); + expect(asked).toBe(1); + // The blobs are fetched from the account the message lives in. + expect(accounts).toEqual(['shared-acc']); + }); +}); diff --git a/src/components/email/structured-mail/booking-calendar-actions.tsx b/src/components/email/structured-mail/booking-calendar-actions.tsx new file mode 100644 index 00000000..4ff75ce5 --- /dev/null +++ b/src/components/email/structured-mail/booking-calendar-actions.tsx @@ -0,0 +1,198 @@ +// Port of webmail components/email/structured-mail/booking-calendar-actions.tsx +// at 3aac5a5c4, on React Native views. App: the calendar picker opens as a +// list under the button (CalendarInvitationBanner's picker) instead of a +// floating menu, and "View in calendar" opens the event on the Calendar tab. +import React from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import type { Email } from '../../../api/types'; +import { getCalendarColor } from '../../../lib/calendar-utils'; +import type { StructuredCard } from '../../../lib/structured-mail'; +import type { RootStackParamList } from '../../../navigation/types'; +import { useLocaleStore } from '../../../stores/locale-store'; +import { useColors } from '../../../theme/colors'; +import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens'; +import { ArrowRight, CalendarDays, CalendarPlus, ChevronDown } from '../../icons'; +import { CalendarCheck, CalendarX } from './icons'; +import { useBookingCalendar } from './use-booking-calendar'; + +type Nav = NativeStackNavigationProp; + +/** + * The calendar part of a booking card's action row: "Add to calendar" (with + * a calendar picker when there is more than one calendar to choose from), + * "Update in calendar", "Remove from calendar", the "In your calendar" pill + * and "View in calendar". Renders nothing when the booking has no calendar + * action (see useBookingCalendar). + */ +export function BookingCalendarActions({ booking, email }: { booking: StructuredCard; email: Pick }) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const navigation = useNavigation