diff --git a/src/components/email/structured-mail/StructuredMailBanner.tsx b/src/components/email/structured-mail/StructuredMailBanner.tsx
new file mode 100644
index 00000000..8e75a7e3
--- /dev/null
+++ b/src/components/email/structured-mail/StructuredMailBanner.tsx
@@ -0,0 +1,45 @@
+// 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 { isSenderContentTrusted, isTrustedSendersSyncOn } from '../../../lib/trusted-senders';
+import { useHasContacts } from '../../../lib/capabilities';
+import { useContactsStore } from '../../../stores/contacts-store';
+import { useSettingsStore } from '../../../stores/settings-store';
+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.
+ */
+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
+ );
+ if (cards.length === 0) return null;
+ return ;
+}
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..ab920999
--- /dev/null
+++ b/src/components/email/structured-mail/__tests__/render-native.ts
@@ -0,0 +1,347 @@
+// 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 {
+ const started = Date.now();
+ let last: unknown;
+ for (;;) {
+ try {
+ check();
+ return;
+ } catch (err) {
+ last = err;
+ }
+ if (Date.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..9f393f70
--- /dev/null
+++ b/src/components/email/structured-mail/__tests__/structured-mail-banner.test.ts
@@ -0,0 +1,57 @@
+// 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) }));
+
+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('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-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-cards.tsx b/src/components/email/structured-mail/booking-cards.tsx
new file mode 100644
index 00000000..992a7ea2
--- /dev/null
+++ b/src/components/email/structured-mail/booking-cards.tsx
@@ -0,0 +1,360 @@
+// Port of webmail components/email/structured-mail/booking-cards.tsx at
+// b0782af2d (same cards and rows, React Native views). App difference: the
+// route title is one line of text with isolated stops and an arrow that
+// points the reading direction, where the webmail draws arrow icons.
+import React from 'react';
+import { I18nManager, Text, View } from 'react-native';
+import { ArrowRight, Clock, MapPin, Phone, Users } from '../../icons';
+import {
+ formatAddress,
+ formatMomentDate,
+ formatMomentTime,
+ formatPlace,
+ isOtherZone,
+ isWebLink,
+ legPlaceLabel,
+ legService,
+ nightsBetween,
+ routeRuns,
+ routeTitle,
+ sameLocalDate,
+} from '../../../lib/structured-mail/format';
+import type {
+ Booking,
+ EventBooking,
+ Link,
+ LodgingBooking,
+ Moment,
+ RentalCarBooking,
+ RestaurantBooking,
+ TransportBooking,
+ TransportLeg,
+} from '../../../lib/structured-mail/types';
+import { useLocaleStore } from '../../../stores/locale-store';
+import {
+ BookingStatusPill,
+ CardLink,
+ CardShell,
+ LocalTimeHint,
+ MetaRow,
+ Pill,
+ When,
+ eyebrowText,
+ isolate,
+ ltr,
+ useCardFormat,
+ useCardStyles,
+} from './card-parts';
+import { CopyValueChip } from './copy-value-chip';
+import { Bed, Bus, Car, Plane, Restaurant, Ticket, Train } from './icons';
+
+export interface BookingCardProps {
+ booking: B;
+ /** Slot in the action row, for "Add to calendar". */
+ calendarActions?: (booking: Booking) => React.ReactNode;
+}
+
+const TRANSPORT_ICON = { flight: Plane, train: Train, bus: Bus } as const;
+
+interface CardLinkSpec {
+ link?: Link;
+ label: string;
+}
+
+/**
+ * The action row: reference chip, the links this card shows and the
+ * calendar slot. Undefined when there is none of them, so no empty row.
+ */
+function bookingActions(booking: Booking, calendarActions: BookingCardProps['calendarActions'], links: CardLinkSpec[]): React.ReactNode {
+ const shown = links.filter((l) => isWebLink(l.link));
+ if (!booking.reference && !calendarActions && shown.length === 0) return undefined;
+ return (
+
+ {shown.map((l) => )}
+
+ );
+}
+
+function BookingActions({ booking, calendarActions, children }: Pick & { children: React.ReactNode }) {
+ const t = useLocaleStore((s) => s.t);
+ return (
+ <>
+ {booking.reference ? (
+
+ ) : null}
+ {children}
+ {calendarActions?.(booking)}
+ >
+ );
+}
+
+function PassengersRow({ names }: { names: string[] }) {
+ const t = useLocaleStore((s) => s.t);
+ if (names.length === 0) return null;
+ return (
+
+ {names.map(isolate).join(', ')}
+
+ );
+}
+
+function Leg({ leg, kind, bookingCancelled }: { leg: TransportLeg; kind: TransportBooking['kind']; bookingCancelled: boolean }) {
+ const t = useLocaleStore((s) => s.t);
+ const { c, styles } = useCardStyles();
+ const cancelled = leg.status === 'cancelled';
+ const service = legService(leg);
+ const Icon = TRANSPORT_ICON[kind];
+ const arrival = leg.arrival;
+
+ return (
+
+ {(service || (cancelled && !bookingCancelled)) ? (
+
+ {service ? (
+
+
+ {service}
+
+ ) : null}
+ {cancelled && !bookingCancelled ? (
+
+ ) : null}
+
+ ) : null}
+
+
+ · {isolate(legPlaceLabel(leg.departure.place, kind))}
+
+ {arrival ? (
+
+ {arrival.at ? : null}
+ {arrival.at ? ' · ' : ''}{isolate(legPlaceLabel(arrival.place, kind))}
+
+ ) : null}
+ {leg.seats.length > 0 ? (
+
+ {t('email_viewer.structured_mail.seats', 'Seats')}:{' '}
+ {leg.seats.map((seat) => `${seat.passenger ? `${isolate(seat.passenger)} ` : ''}${ltr(seat.seat)}`).join(', ')}
+
+ ) : null}
+
+ );
+}
+
+/** "FRA → JFK → FRA", every stop isolated, the arrow pointing the reading direction. */
+export function routeText(runs: string[][], rtl: boolean): string {
+ const arrow = rtl ? ' ← ' : ' → ';
+ return runs.map((run) => run.map(isolate).join(arrow)).join(', ');
+}
+
+export function TransportCard({ booking, calendarActions }: BookingCardProps) {
+ const t = useLocaleStore((s) => s.t);
+ const { styles } = useCardStyles();
+ const kindLabel = booking.kind === 'flight'
+ ? t('email_viewer.structured_mail.kind_flight', 'Flight')
+ : booking.kind === 'train'
+ ? t('email_viewer.structured_mail.kind_train', 'Train')
+ : t('email_viewer.structured_mail.kind_bus', 'Bus');
+ const name = booking.kind === 'flight'
+ ? t('email_viewer.structured_mail.region_flight', 'Flight booking')
+ : booking.kind === 'train'
+ ? t('email_viewer.structured_mail.region_train', 'Train booking')
+ : t('email_viewer.structured_mail.region_bus', 'Bus booking');
+ const runs = routeRuns(booking);
+ const title = routeText(runs, I18nManager.isRTL);
+ const cancelled = booking.status === 'cancelled';
+ return (
+ run.reduce((from, to) => t('email_viewer.structured_mail.route', '{from} to {to}', { from, to }))).join(', ')}
+ pills={booking.status && booking.status !== 'confirmed' ? : undefined}
+ actions={bookingActions(booking, calendarActions, [
+ { link: booking.links.checkin, label: t('email_viewer.structured_mail.link_check_in', 'Check in') },
+ { link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
+ { link: booking.links.ticket, label: t('email_viewer.structured_mail.link_ticket', 'Ticket') },
+ ])}
+ >
+
+ {booking.legs.map((leg, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+/** "Fri, Dec 4, from 15:00" / "Sun, Dec 6, until 11:00" / the date alone. */
+function StayEnd({ label, at, edge }: { label: string; at: Moment; edge: 'from' | 'until' }) {
+ const t = useLocaleStore((s) => s.t);
+ const { styles } = useCardStyles();
+ const { opts, readerZone } = useCardFormat();
+ const time = formatMomentTime(at, opts);
+ return (
+
+ {label}{' '}
+ {formatMomentDate(at, opts)}
+ {time ? (
+ <>
+ {', '}
+
+ {edge === 'from'
+ ? t('email_viewer.structured_mail.from_time', 'from {time}', { time })
+ : t('email_viewer.structured_mail.until_time', 'until {time}', { time })}
+
+ {isOtherZone(at, readerZone) ? : null}
+ >
+ ) : null}
+
+ );
+}
+
+export function LodgingCard({ booking, calendarActions }: BookingCardProps) {
+ const t = useLocaleStore((s) => s.t);
+ const { opts } = useCardFormat();
+ const nights = nightsBetween(booking.checkin, booking.checkout);
+ const address = formatAddress(booking.address, opts.locale);
+ return (
+ : undefined}
+ actions={bookingActions(booking, calendarActions, [
+ { link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
+ { link: booking.links.checkin, label: t('email_viewer.structured_mail.link_check_in', 'Check in') },
+ ])}
+ >
+ {address ? {address} : null}
+
+
+ {nights !== undefined ? (
+ {t('email_viewer.structured_mail.nights', '{count, plural, one {# night} other {# nights}}', { count: nights })}
+ ) : null}
+ {booking.phone ? {ltr(booking.phone)} : null}
+
+
+ );
+}
+
+export function RentalCarCard({ booking, calendarActions }: BookingCardProps) {
+ const t = useLocaleStore((s) => s.t);
+ const title = booking.company?.trim() || t('email_viewer.structured_mail.kind_rentalcar', 'Rental car');
+ const pickupPlace = formatPlace(booking.pickup.place);
+ const dropoffPlace = formatPlace(booking.dropoff.place);
+ return (
+ : undefined}
+ actions={bookingActions(booking, calendarActions, [
+ { link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
+ ])}
+ >
+
+ {t('email_viewer.structured_mail.pickup', 'Pick-up')}{' '}
+
+ {pickupPlace ? ` · ${pickupPlace}` : null}
+
+
+ {t('email_viewer.structured_mail.dropoff', 'Return')}{' '}
+
+ {dropoffPlace ? ` · ${dropoffPlace}` : null}
+
+ {booking.car ? {booking.car} : null}
+
+
+ );
+}
+
+export function EventCard({ booking, calendarActions }: BookingCardProps) {
+ const t = useLocaleStore((s) => s.t);
+ const { styles } = useCardStyles();
+ const { opts } = useCardFormat();
+ const venueName = booking.venue?.name?.trim();
+ const venueAddress = formatAddress(booking.venue?.address, opts.locale);
+ // "Stadtpark, Parkweg 2, Hamburg"; the name alone, or the address alone, when that's all there is.
+ const venue = venueName && venueAddress
+ ? (venueAddress.includes(venueName) ? venueAddress : `${venueName}, ${venueAddress}`)
+ : venueName || venueAddress || formatPlace(booking.venue);
+ const end = booking.end;
+ return (
+ : undefined}
+ actions={bookingActions(booking, calendarActions, [
+ { link: booking.links.ticket, label: t('email_viewer.structured_mail.link_ticket', 'Ticket') },
+ { link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
+ ])}
+ >
+
+
+ {end && (end.time || !sameLocalDate(end, booking.start)) ? (
+ <>
+ {' – '}
+
+ >
+ ) : null}
+
+ {venue ? {venue} : null}
+ {booking.tickets.length > 0 ? (
+
+ {t('email_viewer.structured_mail.tickets', 'Tickets')}:{' '}
+ {ltr(booking.tickets.join(', '))}
+
+ ) : null}
+
+
+ );
+}
+
+export function RestaurantCard({ booking, calendarActions }: BookingCardProps) {
+ const t = useLocaleStore((s) => s.t);
+ const { opts } = useCardFormat();
+ const address = formatAddress(booking.address, opts.locale);
+ return (
+ : undefined}
+ actions={bookingActions(booking, calendarActions, [
+ { link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
+ ])}
+ >
+
+ {address ? {address} : null}
+ {booking.partySize !== undefined && booking.partySize > 0 ? (
+
+ {t('email_viewer.structured_mail.party_size', '{count, plural, one {Table for #} other {Table for #}}', { count: booking.partySize })}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/email/structured-mail/card-parts.tsx b/src/components/email/structured-mail/card-parts.tsx
new file mode 100644
index 00000000..ea1caf60
--- /dev/null
+++ b/src/components/email/structured-mail/card-parts.tsx
@@ -0,0 +1,260 @@
+// Port of webmail components/email/structured-mail/card-parts.tsx at
+// b0782af2d (same parts, React Native views). App differences: links open
+// through the URL guard (lib/open-url), the reader's zone is the calendar
+// zone setting, and isolation is done with Unicode isolates.
+import React from 'react';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { ExternalLink, type AppIcon } from '../../icons';
+import { resolveTimeZone } from '../../../lib/calendar-timezone';
+import { openExternalUrl } from '../../../lib/open-url';
+import { formatMoment, formatMomentTime, isOtherZone, isWebLink, type FormatOptions } from '../../../lib/structured-mail/format';
+import type { Link, Moment, ReservationStatus } from '../../../lib/structured-mail/types';
+import { useLocaleStore } from '../../../stores/locale-store';
+import { useSettingsStore } from '../../../stores/settings-store';
+import { useColors } from '../../../theme/colors';
+import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens';
+
+/** First strong isolate / left-to-right isolate / pop: what and do. */
+const FSI = String.fromCharCode(0x2068);
+const LRI = String.fromCharCode(0x2066);
+const PDI = String.fromCharCode(0x2069);
+
+/** Text that keeps its own direction inside a sentence in the other one (a ). */
+export function isolate(text: string): string {
+ return `${FSI}${text}${PDI}`;
+}
+
+/** Digits and codes that read left to right in a right-to-left UI (). */
+export function ltr(text: string): string {
+ return `${LRI}${text}${PDI}`;
+}
+
+export function makeCardStyles(c: ThemePalette) {
+ return StyleSheet.create({
+ list: { gap: spacing.md, marginHorizontal: spacing.lg, marginBottom: spacing.md },
+ card: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: spacing.md,
+ padding: spacing.md,
+ borderRadius: radius.md,
+ borderWidth: 1,
+ borderColor: c.border,
+ backgroundColor: c.surface,
+ },
+ badge: {
+ width: 40, height: 40, borderRadius: 20,
+ alignItems: 'center', justifyContent: 'center',
+ backgroundColor: c.primaryBg,
+ },
+ badgeCancelled: { backgroundColor: c.errorBg },
+ body: { flex: 1, minWidth: 0, gap: spacing.sm },
+ eyebrow: { ...typography.small, color: c.textMuted, fontWeight: '600' },
+ title: { ...typography.bodySemibold, color: c.text },
+ titleCancelled: { color: c.textMuted, textDecorationLine: 'line-through' },
+ pills: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6 },
+ details: { gap: 4 },
+ actions: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6, paddingTop: 2 },
+ metaRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 },
+ metaIcon: { marginTop: 3 },
+ metaText: { flex: 1, minWidth: 0, ...typography.body, color: c.textSecondary },
+ strong: { color: c.text },
+ struck: { textDecorationLine: 'line-through' },
+ hint: { ...typography.caption, color: c.textMuted },
+ pill: { borderRadius: radius.full, paddingHorizontal: 8, paddingVertical: 2 },
+ pillText: { ...typography.small },
+ action: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ gap: 6,
+ minHeight: 36,
+ maxWidth: '100%',
+ paddingHorizontal: spacing.md,
+ paddingVertical: 6,
+ borderRadius: radius.md,
+ borderWidth: 1,
+ borderColor: c.border,
+ backgroundColor: c.background,
+ },
+ actionPressed: { backgroundColor: c.surfaceHover },
+ actionText: { ...typography.body, color: c.textSecondary },
+ actionHost: { ...typography.body, color: c.textMuted, flexShrink: 1 },
+ legs: { gap: spacing.sm },
+ leg: { gap: 2 },
+ legHead: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', columnGap: spacing.sm, rowGap: 4 },
+ legService: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 },
+ legServiceText: { ...typography.bodyMedium, color: c.text, flexShrink: 1 },
+ mono: { fontFamily: 'monospace' },
+ images: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingTop: 2 },
+ image: { width: 40, height: 40, borderRadius: radius.md, borderWidth: 1, borderColor: c.border, backgroundColor: c.background },
+ summary: { ...typography.body, color: c.textSecondary, paddingStart: 20 },
+ });
+}
+
+export type CardStyles = ReturnType;
+
+/** The palette and the card styles for it. */
+export function useCardStyles(): { c: ThemePalette; styles: CardStyles } {
+ const c = useColors();
+ const styles = React.useMemo(() => makeCardStyles(c), [c]);
+ return { c, styles };
+}
+
+/** Locale, clock and the reader's zone, for formatting a card's times. */
+export function useCardFormat(): { opts: FormatOptions; readerZone: string } {
+ const locale = useLocaleStore((s) => s.locale);
+ const timeFormat = useSettingsStore((s) => s.timeFormat);
+ const timeZone = useSettingsStore((s) => s.calendarTimeZone);
+ return {
+ opts: { locale, hour12: timeFormat === '12h' ? true : timeFormat === '24h' ? false : undefined },
+ readerZone: resolveTimeZone(timeZone),
+ };
+}
+
+/**
+ * A time as the mail gave it, at the place, as nested text for a MetaRow.
+ * `timeOnly` leaves out the date (an arrival on the departure day). Marked
+ * "local time" when the place's clock differs from the reader's at that
+ * instant.
+ */
+export function When({ at, timeOnly = false, struck = false }: { at: Moment; timeOnly?: boolean; struck?: boolean }) {
+ const { styles } = useCardStyles();
+ const { opts, readerZone } = useCardFormat();
+ const text = timeOnly && at.time ? formatMomentTime(at, opts) : formatMoment(at, opts);
+ const other = isOtherZone(at, readerZone);
+ return (
+ <>
+ {text}
+ {other && }
+ >
+ );
+}
+
+/** The "local time" mark after a time written at the place. */
+export function LocalTimeHint() {
+ const t = useLocaleStore((s) => s.t);
+ const { styles } = useCardStyles();
+ return (
+
+ {' '}{t('email_viewer.structured_mail.local_time', 'local time')}
+
+ );
+}
+
+/** One meta row: a small icon and text that wraps instead of overflowing. */
+export function MetaRow({ icon: Icon, children, testID }: { icon: AppIcon; children: React.ReactNode; testID?: string }) {
+ const { c, styles } = useCardStyles();
+ return (
+
+
+ {children}
+
+ );
+}
+
+/** A small rounded label in one of the status tones. */
+export function Pill({ label, tone }: { label: string; tone: 'error' | 'warning' | 'success' | 'info' | 'muted' }) {
+ const { c, styles } = useCardStyles();
+ const colors = {
+ error: { bg: c.errorBg, fg: c.error },
+ warning: { bg: c.warningBg, fg: c.warning },
+ success: { bg: c.successBg, fg: c.success },
+ info: { bg: c.primaryBg, fg: c.info },
+ muted: { bg: c.surfaceHover, fg: c.textSecondary },
+ }[tone];
+ return (
+
+ {label}
+
+ );
+}
+
+/** Cancelled, Pending or On hold; nothing for a confirmed booking. */
+export function BookingStatusPill({ status }: { status?: ReservationStatus }) {
+ const t = useLocaleStore((s) => s.t);
+ if (!status || status === 'confirmed') return null;
+ const label = status === 'cancelled'
+ ? t('email_viewer.structured_mail.status_cancelled', 'Cancelled')
+ : status === 'pending'
+ ? t('email_viewer.structured_mail.status_pending', 'Pending')
+ : t('email_viewer.structured_mail.status_hold', 'On hold');
+ return ;
+}
+
+/**
+ * A link from the mail with its host beside the label ("Track · dhl.de").
+ * Opens through the app's URL guard, which only hands http(s) to the OS.
+ */
+export function CardLink({ link, label, icon: Icon = ExternalLink }: { link?: Link; label: string; icon?: AppIcon }) {
+ const { c, styles } = useCardStyles();
+ if (!link || !isWebLink(link)) return null;
+ return (
+ { void openExternalUrl(link.url); }}
+ style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
+ accessibilityRole="link"
+ accessibilityLabel={`${label}, ${link.host}`}
+ testID="structured-mail-link"
+ >
+
+ {label}
+ {/* The host is how a reader judges the link: wrapped, never cut off. */}
+ · {link.host}
+
+ );
+}
+
+interface CardShellProps {
+ /** Accessible name of the card ("Flight booking"). */
+ name: string;
+ icon: AppIcon;
+ /** Destructive tone and a struck-through title. */
+ cancelled?: boolean;
+ eyebrow: string;
+ title: string;
+ /** What screen readers hear instead of the title (a route written with arrows). */
+ titleLabel?: string;
+ pills?: React.ReactNode;
+ children?: React.ReactNode;
+ actions?: React.ReactNode;
+ kind: string;
+}
+
+/** The banner layout every card shares (CalendarInvitationBanner's look). */
+export function CardShell({ name, icon: Icon, cancelled = false, eyebrow, title, titleLabel, pills, children, actions, kind }: CardShellProps) {
+ const { c, styles } = useCardStyles();
+ return (
+
+
+
+
+
+
+ {eyebrow}
+
+ {title}
+
+
+ {pills ? {pills} : null}
+ {children ? {children} : null}
+ {actions ? {actions} : null}
+
+
+ );
+}
+
+/** "Flight · Lufthansa"; the kind alone when the provider is missing or already the title. */
+export function eyebrowText(kind: string, provider: string | undefined, title: string): string {
+ const p = provider?.trim();
+ return p && p.toLowerCase() !== title.trim().toLowerCase() ? `${kind} · ${p}` : kind;
+}
diff --git a/src/components/email/structured-mail/copy-value-chip.tsx b/src/components/email/structured-mail/copy-value-chip.tsx
new file mode 100644
index 00000000..f80c5b2b
--- /dev/null
+++ b/src/components/email/structured-mail/copy-value-chip.tsx
@@ -0,0 +1,86 @@
+// Port of webmail components/email/structured-mail/copy-value-chip.tsx at
+// b0782af2d, on expo-clipboard like the app's VerificationCodeChip.
+import React from 'react';
+import { Pressable, StyleSheet, Text } from 'react-native';
+import * as Clipboard from 'expo-clipboard';
+import { Check, Copy } from '../../icons';
+import { useLocaleStore } from '../../../stores/locale-store';
+import { toast } from '../../../stores/toast-store';
+import { useColors } from '../../../theme/colors';
+import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens';
+import { ltr } from './card-parts';
+
+interface CopyValueChipProps {
+ /** What a tap copies: a booking reference, order or tracking number. */
+ value: string;
+ /** The button's accessible name, naming what is copied ("Copy booking reference QX7K2M"). */
+ copyLabel: string;
+ /** The toast after copying ("Booking reference copied"). */
+ copiedMessage: string;
+ /** A visible label in front of the value ("Booking reference"). */
+ label?: string;
+ /** As tall as the buttons beside it, in an action row. */
+ inActions?: boolean;
+}
+
+/** A number from a booking or parcel card; a tap copies it. Looks like the verification code chip. */
+export function CopyValueChip({ value, copyLabel, copiedMessage, label, inActions }: CopyValueChipProps) {
+ const c = useColors();
+ const styles = React.useMemo(() => makeStyles(c), [c]);
+ const t = useLocaleStore((s) => s.t);
+ const [copied, setCopied] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!copied) return undefined;
+ const timer = setTimeout(() => setCopied(false), 2000);
+ return () => clearTimeout(timer);
+ }, [copied]);
+
+ const copy = () => {
+ Clipboard.setStringAsync(value).then(
+ () => {
+ setCopied(true);
+ toast.success(copiedMessage);
+ },
+ () => toast.error(t('email_viewer.structured_mail.copy_failed', 'Could not copy to the clipboard')),
+ );
+ };
+
+ const Icon = copied ? Check : Copy;
+ return (
+ [styles.chip, inActions && styles.chipInActions, pressed && styles.chipPressed]}
+ accessibilityRole="button"
+ accessibilityLabel={copyLabel}
+ testID="copy-value-chip"
+ >
+
+ {label ? {label} : null}
+ {/* Digit groups keep their order in a right-to-left UI. */}
+ {ltr(value)}
+
+ );
+}
+
+function makeStyles(c: ThemePalette) {
+ return StyleSheet.create({
+ chip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flexShrink: 1,
+ gap: 4,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 3,
+ borderWidth: 1,
+ borderColor: c.border,
+ borderRadius: radius.sm,
+ backgroundColor: c.background,
+ },
+ chipInActions: { minHeight: 36, paddingHorizontal: spacing.md, borderRadius: radius.md },
+ chipPressed: { backgroundColor: c.surfaceHover },
+ label: { ...typography.caption, color: c.textMuted },
+ value: { ...typography.caption, color: c.text, fontWeight: '600', fontFamily: 'monospace', letterSpacing: 0.5, flexShrink: 1 },
+ });
+}
diff --git a/src/components/email/structured-mail/icons.ts b/src/components/email/structured-mail/icons.ts
new file mode 100644
index 00000000..0662dcd1
--- /dev/null
+++ b/src/components/email/structured-mail/icons.ts
@@ -0,0 +1,19 @@
+// The card glyphs the app's generated icon set (src/components/icons, from
+// scripts/generate-icons.mjs) doesn't ship yet, from the same Tabler outline
+// data (MIT) under the webmail's names. Once they are added to MAP in that
+// script, import them from components/icons and delete this file.
+
+import { makeIcon } from '../../icons/icon';
+
+export const Bed = makeIcon('Bed', ["M5 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M22 17v-3h-20", "M2 8v9", "M12 14h10v-2a3 3 0 0 0 -3 -3h-7v5"]);
+export const Bus = makeIcon('Bus', ["M4 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M16 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M4 17h-2v-11a1 1 0 0 1 1 -1h14a5 7 0 0 1 5 7v5h-2m-4 0h-8", "M16 5l1.5 7l4.5 0", "M2 10l15 0", "M7 5l0 5", "M12 5l0 5"]);
+export const CalendarCheck = makeIcon('CalendarCheck', ["M11.5 21h-5.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6", "M16 3v4", "M8 3v4", "M4 11h16", "M15 19l2 2l4 -4"]);
+export const CalendarX = makeIcon('CalendarX', ["M13 21h-7a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6.5", "M16 3v4", "M8 3v4", "M4 11h16", "M22 22l-5 -5", "M17 22l5 -5"]);
+export const Car = makeIcon('Car', ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M5 17h-2v-6l2 -5h9l4 5h1a2 2 0 0 1 2 2v4h-2m-4 0h-6m-6 -6h15m-6 0v-5"]);
+export const Package = makeIcon('Package', ["M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5", "M12 12l8 -4.5", "M12 12l0 9", "M12 12l-8 -4.5", "M16 5.25l-8 4.5"]);
+export const Plane = makeIcon('Plane', ["M16 10h4a2 2 0 0 1 0 4h-4l-4 7h-3l2 -7h-4l-2 2h-3l2 -4l-2 -4h3l2 2h4l-2 -7h3l4 7"]);
+export const Restaurant = makeIcon('Restaurant', ["M19 3v12h-5c-.023 -3.681 .184 -7.406 5 -12m0 12v6h-1v-3m-10 -14v17m-3 -17v3a3 3 0 1 0 6 0v-3"]);
+export const ShoppingBag = makeIcon('ShoppingBag', ["M6.331 8h11.339a2 2 0 0 1 1.977 2.304l-1.255 8.152a3 3 0 0 1 -2.966 2.544h-6.852a3 3 0 0 1 -2.965 -2.544l-1.255 -8.152a2 2 0 0 1 1.977 -2.304", "M9 11v-5a3 3 0 0 1 6 0v5"]);
+export const Ticket = makeIcon('Ticket', ["M15 5l0 2", "M15 11l0 2", "M15 17l0 2", "M5 5h14a2 2 0 0 1 2 2v3a2 2 0 0 0 0 4v3a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-3a2 2 0 0 0 0 -4v-3a2 2 0 0 1 2 -2"]);
+export const Train = makeIcon('Train', ["M21 13c0 -3.87 -3.37 -7 -10 -7h-8", "M3 15h16a2 2 0 0 0 2 -2", "M3 6v5h17.5", "M3 11v4", "M8 11v-5", "M13 11v-4.5", "M3 19h18"]);
+export const Truck = makeIcon('Truck', ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M5 17h-2v-11a1 1 0 0 1 1 -1h9v12m-4 0h6m4 0h2v-6h-8m0 -5h5l3 5"]);
diff --git a/src/components/email/structured-mail/shipment-card.tsx b/src/components/email/structured-mail/shipment-card.tsx
new file mode 100644
index 00000000..0a8671a9
--- /dev/null
+++ b/src/components/email/structured-mail/shipment-card.tsx
@@ -0,0 +1,171 @@
+// Port of webmail components/email/structured-mail/shipment-card.tsx at
+// b0782af2d (same rows, React Native views).
+import React from 'react';
+import { Image, Text, View } from 'react-native';
+import { Clock, ExternalLink } from '../../icons';
+import { formatMomentDate, formatMoney, isWebLink, shipmentItems, summarizeItems } from '../../../lib/structured-mail/format';
+import type { Item, Link, OrderStatus, Parcel, Shipment } from '../../../lib/structured-mail/types';
+import { useLocaleStore } from '../../../stores/locale-store';
+import { CardLink, CardShell, MetaRow, Pill, useCardFormat, useCardStyles } from './card-parts';
+import { CopyValueChip } from './copy-value-chip';
+import { Package, ShoppingBag, Truck } from './icons';
+
+const STATUS_TONE: Record = {
+ payment_due: 'warning',
+ processing: 'muted',
+ problem: 'error',
+ in_transit: 'info',
+ pickup_available: 'success',
+ returned: 'muted',
+ delivered: 'success',
+ cancelled: 'error',
+};
+
+/** States in which an expected arrival date is out of date. */
+const ARRIVED_OR_STOPPED = new Set(['delivered', 'returned', 'cancelled']);
+
+function OrderStatusPill({ status }: { status: OrderStatus }) {
+ const t = useLocaleStore((s) => s.t);
+ const labels: Record = {
+ payment_due: t('email_viewer.structured_mail.order_status_payment_due', 'Payment due'),
+ processing: t('email_viewer.structured_mail.order_status_processing', 'Processing'),
+ problem: t('email_viewer.structured_mail.order_status_problem', 'Problem'),
+ in_transit: t('email_viewer.structured_mail.order_status_in_transit', 'Shipped'),
+ pickup_available: t('email_viewer.structured_mail.order_status_pickup_available', 'Ready for pickup'),
+ returned: t('email_viewer.structured_mail.order_status_returned', 'Returned'),
+ delivered: t('email_viewer.structured_mail.order_status_delivered', 'Delivered'),
+ cancelled: t('email_viewer.structured_mail.order_status_cancelled', 'Cancelled'),
+ };
+ return ;
+}
+
+/** Product images, only when remote content may load for this message; https only. */
+function ItemImages({ items }: { items: Item[] }) {
+ const { styles } = useCardStyles();
+ const shown = items.filter((item) => item.image && isWebLink(item.image) && item.image.url.toLowerCase().startsWith('https:'));
+ if (shown.length === 0) return null;
+ return (
+
+ {shown.map((item, i) => (
+
+ ))}
+
+ );
+}
+
+/** Carrier, tracking number and expected date; no date once the order is delivered, returned or cancelled. */
+function ParcelRows({ parcel, showExpected }: { parcel: Parcel; showExpected: boolean }) {
+ const t = useLocaleStore((s) => s.t);
+ const { c, styles } = useCardStyles();
+ const { opts } = useCardFormat();
+ const expectedBy = showExpected ? parcel.expectedBy : undefined;
+ if (!parcel.carrier && !parcel.trackingNumber && !expectedBy) return null;
+ return (
+ <>
+ {(parcel.carrier || parcel.trackingNumber) ? (
+
+
+
+ {parcel.carrier ? {parcel.carrier} : null}
+ {!parcel.carrier && parcel.trackingNumber ? (
+ {t('email_viewer.structured_mail.tracking_number', 'Tracking number')}
+ ) : null}
+ {parcel.trackingNumber ? (
+
+ ) : null}
+
+
+ ) : null}
+ {expectedBy ? (
+ {t('email_viewer.structured_mail.expected_by', 'Arrives by {date}', { date: formatMomentDate(expectedBy, opts) })}
+ ) : null}
+ >
+ );
+}
+
+interface ShipmentCardProps {
+ shipment: Shipment;
+ allowRemoteImages: boolean;
+}
+
+export function ShipmentCard({ shipment, allowRemoteImages }: ShipmentCardProps) {
+ const t = useLocaleStore((s) => s.t);
+ const { c, styles } = useCardStyles();
+ const { opts } = useCardFormat();
+ const carrier = shipment.parcels.find((p) => p.carrier)?.carrier;
+ const isParcel = !shipment.orderNumber && !shipment.merchant && shipment.parcels.length > 0;
+ const kindLabel = isParcel
+ ? t('email_viewer.structured_mail.kind_parcel', 'Parcel')
+ : t('email_viewer.structured_mail.kind_order', 'Order');
+ const title = shipment.merchant?.trim() || carrier || kindLabel;
+ const eyebrow = shipment.merchant && carrier ? `${kindLabel} · ${carrier}` : kindLabel;
+ const items = shipmentItems(shipment);
+ const { shown, more, count } = summarizeItems(items);
+ const total = shipment.total ? formatMoney(shipment.total, opts.locale) : undefined;
+ // One Track button per distinct tracking page.
+ const trackLinks = shipment.parcels.map((p) => p.trackingUrl)
+ .filter((l, i, all): l is Link => isWebLink(l) && all.findIndex((o) => o?.url === l!.url) === i);
+ const hasOrderLink = isWebLink(shipment.orderUrl);
+ const cancelled = shipment.status === 'cancelled';
+ const summary = [
+ items.length > 0 ? t('email_viewer.structured_mail.items_count', '{count, plural, one {# item} other {# items}}', { count }) : undefined,
+ total ? t('email_viewer.structured_mail.total', 'Total {total}', { total }) : undefined,
+ ].filter(Boolean).join(' · ');
+
+ return (
+ : undefined}
+ actions={trackLinks.length > 0 || hasOrderLink ? (
+ <>
+ {trackLinks.map((link, i) => (
+
+ ))}
+
+ >
+ ) : undefined}
+ >
+ {shipment.orderNumber ? (
+
+
+
+ {t('email_viewer.structured_mail.order_number', 'Order number')}
+
+
+
+ ) : null}
+ {shown.length > 0 ? (
+
+
+ {shown.map((item) => item.name).join(', ')}
+ {more > 0 ? ` ${t('email_viewer.structured_mail.items_more', 'and {count} more', { count: more })}` : null}
+
+ {allowRemoteImages ? : null}
+
+ ) : null}
+ {summary ? {summary} : null}
+ {shipment.parcels.map((parcel, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/email/structured-mail/sources.ts b/src/components/email/structured-mail/sources.ts
new file mode 100644
index 00000000..ff45465d
--- /dev/null
+++ b/src/components/email/structured-mail/sources.ts
@@ -0,0 +1,26 @@
+// Mirrors webmail components/email/structured-mail/sources.ts at 66c73d0f4;
+// keep the two in step. App: the blob context is the JMAP account id alone,
+// since the app has one client (api/jmap-client).
+import type { Email } from '../../../api/types';
+import type { StructuredCard } from '../../../lib/structured-mail';
+
+/**
+ * Card sources beyond the HTML markup, read asynchronously once a trusted
+ * message is open (Apple Wallet passes among its attachments). Each gets the
+ * message and returns cards that are validated but not zone-resolved; the
+ * hook merges them with the markup cards and resolves zones for all. A
+ * source must read only `email.attachments` (never parts unwrapped from an
+ * attached message) and must not throw.
+ */
+export interface CardSourceContext {
+ email: Email;
+ mailDate: Date;
+ /** JMAP account the message's blobs live in; undefined for the primary account. */
+ accountId: string | undefined;
+ signal: AbortSignal;
+}
+
+export type CardSource = (context: CardSourceContext) => Promise;
+
+/** Registered sources. */
+export const CARD_SOURCES: CardSource[] = [];
diff --git a/src/components/email/structured-mail/structured-mail-cards.tsx b/src/components/email/structured-mail/structured-mail-cards.tsx
new file mode 100644
index 00000000..8487ebd0
--- /dev/null
+++ b/src/components/email/structured-mail/structured-mail-cards.tsx
@@ -0,0 +1,49 @@
+// Port of webmail components/email/structured-mail/structured-mail-cards.tsx
+// at 972e5f621. App: links need no message id (the app has no plugin link
+// hook), so the cards don't take the email.
+import React from 'react';
+import { View } from 'react-native';
+import type { Booking, StructuredCard } from '../../../lib/structured-mail/types';
+import { EventCard, LodgingCard, RentalCarCard, RestaurantCard, TransportCard } from './booking-cards';
+import { useCardStyles } from './card-parts';
+import { ShipmentCard } from './shipment-card';
+
+export interface StructuredMailCardsProps {
+ /** Resolved cards from useStructuredMail; nothing renders for an empty list. */
+ cards: StructuredCard[];
+ /** Whether remote content may load for this message: product images show only then. */
+ allowRemoteImages: boolean;
+ /** Rendered in each booking card's action row ("Add to calendar"); nothing when undefined. */
+ calendarActions?: (booking: Booking) => React.ReactNode;
+}
+
+/** The booking, parcel and order cards shown above an open message. */
+export function StructuredMailCards({ cards, allowRemoteImages, calendarActions }: StructuredMailCardsProps) {
+ const { styles } = useCardStyles();
+ if (cards.length === 0) return null;
+ return (
+
+ {cards.map((card, i) => {
+ const key = `${card.key}-${i}`;
+ switch (card.kind) {
+ case 'flight':
+ case 'train':
+ case 'bus':
+ return ;
+ case 'lodging':
+ return ;
+ case 'rentalcar':
+ return ;
+ case 'event':
+ return ;
+ case 'restaurant':
+ return ;
+ case 'shipment':
+ return ;
+ default:
+ return null;
+ }
+ })}
+
+ );
+}
diff --git a/src/components/email/structured-mail/use-structured-mail.ts b/src/components/email/structured-mail/use-structured-mail.ts
new file mode 100644
index 00000000..ca5276ce
--- /dev/null
+++ b/src/components/email/structured-mail/use-structured-mail.ts
@@ -0,0 +1,83 @@
+// Mirrors webmail components/email/structured-mail/use-structured-mail.ts at
+// ba6d5e448; keep the two in step. App: the blob account is the reader's
+// `jmapAccountId` (a shared mailbox's owner), else the message's own stamp.
+import { useEffect, useMemo, useState } from 'react';
+import type { Email } from '../../../api/types';
+import {
+ loadAirportZones,
+ mergeCandidates,
+ needsAirportZones,
+ readStructuredMailDetailed,
+ resolveCards,
+ type StructuredCard,
+} from '../../../lib/structured-mail';
+import { useStructuredMailEnabled } from '../../../lib/structured-mail/gate';
+import { getStructuredMailTrust, structuredHtmlPart } from '../../../lib/structured-mail/trust';
+import { CARD_SOURCES } from './sources';
+
+function mailDateOf(email: Pick): Date {
+ return new Date(email.sentAt ?? email.receivedAt);
+}
+
+async function resolveAll(cards: StructuredCard[]): Promise {
+ if (!needsAirportZones(cards)) return resolveCards(cards);
+ try {
+ return resolveCards(cards, await loadAirportZones());
+ } catch {
+ // Without the table no flight card can be checked, so none is shown.
+ return resolveCards(cards);
+ }
+}
+
+/**
+ * The booking, parcel and order cards of an open message: empty unless the
+ * gate is on and the message passes getStructuredMailTrust. Cards that
+ * need nothing asynchronous show at once; flights wait for the airport time
+ * zone table, and cards from attachment sources (CARD_SOURCES) are merged in
+ * once read, so a card never shows with unchecked times. A message whose
+ * markup describes a booking (even one validation rejected) is read from the
+ * markup alone: the sources aren't asked, so nothing is downloaded and a pass
+ * can't revive a rejected or cancelled booking or show one booking twice.
+ */
+export function useStructuredMail(email: Email | null | undefined, jmapAccountId?: string): StructuredCard[] {
+ const enabled = useStructuredMailEnabled();
+ const trusted = useMemo(() => !!email && enabled && getStructuredMailTrust(email).trusted, [email, enabled]);
+ const html = email ? structuredHtmlPart(email)?.value : undefined;
+ // An HTML part whose text hasn't loaded yet may still hold booking markup.
+ const htmlPending = html === undefined && !!email?.htmlBody?.some((part) => part.type === 'text/html');
+
+ const { cards: markup, hadBookingMarkup } = useMemo(() => {
+ if (!trusted || !email || !html) return { cards: [] as StructuredCard[], hadBookingMarkup: false };
+ return readStructuredMailDetailed(html, { mailDate: mailDateOf(email) });
+ // email.id and the HTML identify the input; the rest of the object may change identity freely.
+ }, [trusted, email?.id, html]); // eslint-disable-line react-hooks/exhaustive-deps
+ const askSources = !hadBookingMarkup && !htmlPending;
+
+ const immediate = useMemo(() => resolveCards(markup.filter((card) => card.kind !== 'flight')), [markup]);
+ const [settled, setSettled] = useState<{ key: string; cards: StructuredCard[] } | null>(null);
+ const accountId = jmapAccountId ?? email?.jmapAccountId;
+ const settleKey = `${email?.id ?? ''}|${accountId ?? ''}|${trusted}|${markup.length}|${askSources}`;
+
+ useEffect(() => {
+ if (!trusted || !email) return;
+ const sources = askSources ? CARD_SOURCES : [];
+ const needsWork = needsAirportZones(markup) || sources.length > 0;
+ if (!needsWork) return;
+ const controller = new AbortController();
+ (async () => {
+ const fromSources = (await Promise.all(sources.map((source) =>
+ source({ email, mailDate: mailDateOf(email), accountId, signal: controller.signal }).catch(() => [] as StructuredCard[]),
+ ))).flat();
+ const combined = fromSources.length > 0
+ ? mergeCandidates([...markup, ...fromSources].map((card) => ({ card, problems: [] })))
+ : markup;
+ const cards = await resolveAll(combined);
+ if (!controller.signal.aborted) setSettled({ key: settleKey, cards });
+ })();
+ return () => controller.abort();
+ // markup, askSources and settleKey change together with the message.
+ }, [trusted, markup, settleKey]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ if (!trusted) return [];
+ return settled?.key === settleKey ? settled.cards : immediate;
+}