feat: show a card with times and tracking above booking, shipping and order mail

This commit is contained in:
Linus Rath
2026-10-31 04:40:16 +01:00
parent a6066a092c
commit cd677e7787
13 changed files with 1913 additions and 0 deletions
@@ -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 <StructuredMailCards cards={cards} allowRemoteImages={allowRemoteImages} />;
}
@@ -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<string, any>;
children: Array<HostNode | string>;
}
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<HostNode | string>): 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<string, any> };
const { type, props } = element;
if (type === FRAGMENT) {
renderNode(props.children, out);
return;
}
if (typeof type === 'string') {
const children: Array<HostNode | string> = [];
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<HostNode | string> = [];
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<string, any> {
const flatten = (style: unknown): Record<string, any> => {
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<string, any>;
};
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<T> {
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<T>(hook: () => T): HookResult<T> {
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<void> {
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: <T,>(styles: T) => styles, hairlineWidth: 1, flatten: (s: unknown) => s },
Platform: { OS: 'android', Version: 33, select: <T,>(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() {} },
};
}
@@ -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: `<script type="application/ld+json">${JSON.stringify(ORDER)}</script><p>Thanks</p>` } },
};
}
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);
});
});
@@ -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<string, StructuredCard[]>;
const card = <T extends StructuredCard = StructuredCard>(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<StructuredMailCardsProps> = {}): 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<TransportBooking>('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<TransportBooking>('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<TransportBooking>('train-multiple-types');
pending.status = 'pending';
const hold = card<Booking>('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<Shipment>('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<TransportBooking>('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<Booking>('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<Shipment>('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);
});
});
@@ -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) => `<script type="application/ld+json">${JSON.stringify(value)}</script>`;
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: `<html><head>${body}</head><body></body></html>`, 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']);
});
});
@@ -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<B extends Booking = Booking> {
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 (
<BookingActions booking={booking} calendarActions={calendarActions}>
{shown.map((l) => <CardLink key={l.label} link={l.link} label={l.label} />)}
</BookingActions>
);
}
function BookingActions({ booking, calendarActions, children }: Pick<BookingCardProps, 'booking' | 'calendarActions'> & { children: React.ReactNode }) {
const t = useLocaleStore((s) => s.t);
return (
<>
{booking.reference ? (
<CopyValueChip
value={booking.reference}
label={t('email_viewer.structured_mail.reference', 'Booking reference')}
copyLabel={t('email_viewer.structured_mail.copy_reference', 'Copy booking reference {value}', { value: booking.reference })}
copiedMessage={t('email_viewer.structured_mail.copied_reference', 'Booking reference copied')}
inActions
/>
) : null}
{children}
{calendarActions?.(booking)}
</>
);
}
function PassengersRow({ names }: { names: string[] }) {
const t = useLocaleStore((s) => s.t);
if (names.length === 0) return null;
return (
<View accessibilityLabel={`${t('email_viewer.structured_mail.passengers', 'Passengers')}: ${names.join(', ')}`}>
<MetaRow icon={Users}>{names.map(isolate).join(', ')}</MetaRow>
</View>
);
}
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 (
<View style={styles.leg} testID={cancelled ? 'structured-mail-leg-cancelled' : 'structured-mail-leg'}>
{(service || (cancelled && !bookingCancelled)) ? (
<View style={styles.legHead}>
{service ? (
<View style={styles.legService}>
<Icon size={14} color={cancelled ? c.textMuted : c.text} />
<Text style={[styles.legServiceText, cancelled && styles.titleCancelled]}>{service}</Text>
</View>
) : null}
{cancelled && !bookingCancelled ? (
<Pill label={t('email_viewer.structured_mail.status_cancelled', 'Cancelled')} tone="error" />
) : null}
</View>
) : null}
<MetaRow icon={Clock}>
<When at={leg.departure.at} struck={cancelled} />
<Text style={cancelled ? styles.struck : undefined}> · {isolate(legPlaceLabel(leg.departure.place, kind))}</Text>
</MetaRow>
{arrival ? (
<MetaRow icon={ArrowRight}>
{arrival.at ? <When at={arrival.at} timeOnly={sameLocalDate(arrival.at, leg.departure.at)} struck={cancelled} /> : null}
<Text style={cancelled ? styles.struck : undefined}>{arrival.at ? ' · ' : ''}{isolate(legPlaceLabel(arrival.place, kind))}</Text>
</MetaRow>
) : null}
{leg.seats.length > 0 ? (
<MetaRow icon={Ticket} testID="structured-mail-seats">
{t('email_viewer.structured_mail.seats', 'Seats')}:{' '}
{leg.seats.map((seat) => `${seat.passenger ? `${isolate(seat.passenger)} ` : ''}${ltr(seat.seat)}`).join(', ')}
</MetaRow>
) : null}
</View>
);
}
/** "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<TransportBooking>) {
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 (
<CardShell
kind={booking.kind}
name={name}
icon={TRANSPORT_ICON[booking.kind]}
cancelled={cancelled}
eyebrow={eyebrowText(kindLabel, booking.provider, routeTitle(booking))}
title={title}
titleLabel={runs.map((run) => run.reduce((from, to) => t('email_viewer.structured_mail.route', '{from} to {to}', { from, to }))).join(', ')}
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : 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') },
])}
>
<View style={styles.legs}>
{booking.legs.map((leg, i) => (
<Leg key={`${leg.departure.at.date}-${leg.number ?? ''}-${i}`} leg={leg} kind={booking.kind} bookingCancelled={cancelled} />
))}
</View>
<PassengersRow names={booking.passengers} />
</CardShell>
);
}
/** "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 (
<MetaRow icon={Clock}>
{label}{' '}
<Text style={styles.strong}>{formatMomentDate(at, opts)}</Text>
{time ? (
<>
{', '}
<Text style={styles.strong}>
{edge === 'from'
? t('email_viewer.structured_mail.from_time', 'from {time}', { time })
: t('email_viewer.structured_mail.until_time', 'until {time}', { time })}
</Text>
{isOtherZone(at, readerZone) ? <LocalTimeHint /> : null}
</>
) : null}
</MetaRow>
);
}
export function LodgingCard({ booking, calendarActions }: BookingCardProps<LodgingBooking>) {
const t = useLocaleStore((s) => s.t);
const { opts } = useCardFormat();
const nights = nightsBetween(booking.checkin, booking.checkout);
const address = formatAddress(booking.address, opts.locale);
return (
<CardShell
kind="lodging"
name={t('email_viewer.structured_mail.region_lodging', 'Hotel booking')}
icon={Bed}
cancelled={booking.status === 'cancelled'}
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_lodging', 'Hotel'), booking.provider, booking.name)}
title={booking.name}
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : 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 ? <MetaRow icon={MapPin}>{address}</MetaRow> : null}
<StayEnd label={t('email_viewer.structured_mail.check_in_label', 'Check-in')} at={booking.checkin} edge="from" />
<StayEnd label={t('email_viewer.structured_mail.check_out_label', 'Check-out')} at={booking.checkout} edge="until" />
{nights !== undefined ? (
<MetaRow icon={Bed}>{t('email_viewer.structured_mail.nights', '{count, plural, one {# night} other {# nights}}', { count: nights })}</MetaRow>
) : null}
{booking.phone ? <MetaRow icon={Phone}>{ltr(booking.phone)}</MetaRow> : null}
<PassengersRow names={booking.passengers} />
</CardShell>
);
}
export function RentalCarCard({ booking, calendarActions }: BookingCardProps<RentalCarBooking>) {
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 (
<CardShell
kind="rentalcar"
name={t('email_viewer.structured_mail.region_rentalcar', 'Rental car booking')}
icon={Car}
cancelled={booking.status === 'cancelled'}
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_rentalcar', 'Rental car'), booking.provider, title)}
title={title}
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : undefined}
actions={bookingActions(booking, calendarActions, [
{ link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
])}
>
<MetaRow icon={Clock}>
{t('email_viewer.structured_mail.pickup', 'Pick-up')}{' '}
<When at={booking.pickup.at} />
{pickupPlace ? ` · ${pickupPlace}` : null}
</MetaRow>
<MetaRow icon={ArrowRight}>
{t('email_viewer.structured_mail.dropoff', 'Return')}{' '}
<When at={booking.dropoff.at} />
{dropoffPlace ? ` · ${dropoffPlace}` : null}
</MetaRow>
{booking.car ? <MetaRow icon={Car}>{booking.car}</MetaRow> : null}
<PassengersRow names={booking.passengers} />
</CardShell>
);
}
export function EventCard({ booking, calendarActions }: BookingCardProps<EventBooking>) {
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 (
<CardShell
kind="event"
name={t('email_viewer.structured_mail.region_event', 'Event booking')}
icon={Ticket}
cancelled={booking.status === 'cancelled'}
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_event', 'Event'), booking.provider, booking.name)}
title={booking.name}
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : 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') },
])}
>
<MetaRow icon={Clock}>
<When at={booking.start} />
{end && (end.time || !sameLocalDate(end, booking.start)) ? (
<>
{' – '}
<When at={end} timeOnly={sameLocalDate(end, booking.start) && !!booking.start.time} />
</>
) : null}
</MetaRow>
{venue ? <MetaRow icon={MapPin}>{venue}</MetaRow> : null}
{booking.tickets.length > 0 ? (
<MetaRow icon={Ticket} testID="structured-mail-tickets">
{t('email_viewer.structured_mail.tickets', 'Tickets')}:{' '}
<Text style={styles.mono}>{ltr(booking.tickets.join(', '))}</Text>
</MetaRow>
) : null}
<PassengersRow names={booking.passengers} />
</CardShell>
);
}
export function RestaurantCard({ booking, calendarActions }: BookingCardProps<RestaurantBooking>) {
const t = useLocaleStore((s) => s.t);
const { opts } = useCardFormat();
const address = formatAddress(booking.address, opts.locale);
return (
<CardShell
kind="restaurant"
name={t('email_viewer.structured_mail.region_restaurant', 'Restaurant reservation')}
icon={Restaurant}
cancelled={booking.status === 'cancelled'}
eyebrow={eyebrowText(t('email_viewer.structured_mail.kind_restaurant', 'Restaurant'), booking.provider, booking.name)}
title={booking.name}
pills={booking.status && booking.status !== 'confirmed' ? <BookingStatusPill status={booking.status} /> : undefined}
actions={bookingActions(booking, calendarActions, [
{ link: booking.links.manage, label: t('email_viewer.structured_mail.link_manage', 'Manage booking') },
])}
>
<MetaRow icon={Clock}><When at={booking.start} /></MetaRow>
{address ? <MetaRow icon={MapPin}>{address}</MetaRow> : null}
{booking.partySize !== undefined && booking.partySize > 0 ? (
<MetaRow icon={Users}>
{t('email_viewer.structured_mail.party_size', '{count, plural, one {Table for #} other {Table for #}}', { count: booking.partySize })}
</MetaRow>
) : null}
</CardShell>
);
}
@@ -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 <bdi> 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 <bdi> and <bdi dir="ltr"> 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 <bdi>). */
export function isolate(text: string): string {
return `${FSI}${text}${PDI}`;
}
/** Digits and codes that read left to right in a right-to-left UI (<bdi dir="ltr">). */
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<typeof makeCardStyles>;
/** 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 style={[styles.strong, struck && styles.struck]}>{text}</Text>
{other && <LocalTimeHint />}
</>
);
}
/** The "local time" mark after a time written at the place. */
export function LocalTimeHint() {
const t = useLocaleStore((s) => s.t);
const { styles } = useCardStyles();
return (
<Text
style={styles.hint}
testID="local-time-hint"
accessibilityHint={t('email_viewer.structured_mail.local_time_title', 'Local time at the place, not your time zone')}
>
{' '}{t('email_viewer.structured_mail.local_time', 'local time')}
</Text>
);
}
/** 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 (
<View style={styles.metaRow} testID={testID}>
<Icon size={14} color={c.textSecondary} style={styles.metaIcon} />
<Text style={styles.metaText}>{children}</Text>
</View>
);
}
/** 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 (
<View style={[styles.pill, { backgroundColor: colors.bg }]} testID="structured-mail-pill">
<Text style={[styles.pillText, { color: colors.fg }]} numberOfLines={1}>{label}</Text>
</View>
);
}
/** 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 <Pill label={label} tone={status === 'cancelled' ? 'error' : 'warning'} />;
}
/**
* 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 (
<Pressable
onPress={() => { void openExternalUrl(link.url); }}
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
accessibilityRole="link"
accessibilityLabel={`${label}, ${link.host}`}
testID="structured-mail-link"
>
<Icon size={14} color={c.textSecondary} />
<Text style={styles.actionText} numberOfLines={1}>{label}</Text>
{/* The host is how a reader judges the link: wrapped, never cut off. */}
<Text style={styles.actionHost}>· {link.host}</Text>
</Pressable>
);
}
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 (
<View style={styles.card} accessibilityLabel={name} testID={`structured-mail-card-${kind}`}>
<View style={[styles.badge, cancelled && styles.badgeCancelled]} importantForAccessibility="no-hide-descendants">
<Icon size={20} color={cancelled ? c.error : c.primary} />
</View>
<View style={styles.body}>
<View>
<Text style={styles.eyebrow}>{eyebrow}</Text>
<Text
style={[styles.title, cancelled && styles.titleCancelled]}
accessibilityRole="header"
accessibilityLabel={titleLabel}
testID="structured-mail-title"
>
{title}
</Text>
</View>
{pills ? <View style={styles.pills}>{pills}</View> : null}
{children ? <View style={styles.details}>{children}</View> : null}
{actions ? <View style={styles.actions} testID="structured-mail-actions">{actions}</View> : null}
</View>
</View>
);
}
/** "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;
}
@@ -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 (
<Pressable
onPress={copy}
hitSlop={6}
style={({ pressed }) => [styles.chip, inActions && styles.chipInActions, pressed && styles.chipPressed]}
accessibilityRole="button"
accessibilityLabel={copyLabel}
testID="copy-value-chip"
>
<Icon size={12} color={copied ? c.success : c.textMuted} />
{label ? <Text style={styles.label}>{label}</Text> : null}
{/* Digit groups keep their order in a right-to-left UI. */}
<Text style={styles.value} selectable>{ltr(value)}</Text>
</Pressable>
);
}
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 },
});
}
@@ -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"]);
@@ -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<OrderStatus, 'error' | 'warning' | 'success' | 'info' | 'muted'> = {
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<OrderStatus>(['delivered', 'returned', 'cancelled']);
function OrderStatusPill({ status }: { status: OrderStatus }) {
const t = useLocaleStore((s) => s.t);
const labels: Record<OrderStatus, string> = {
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 <Pill label={labels[status]} tone={STATUS_TONE[status]} />;
}
/** 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 (
<View style={styles.images}>
{shown.map((item, i) => (
<Image
key={`${item.image!.url}-${i}`}
source={{ uri: item.image!.url }}
accessibilityIgnoresInvertColors
style={styles.image}
testID="structured-mail-item-image"
/>
))}
</View>
);
}
/** 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) ? (
<View style={[styles.metaRow, { alignItems: 'center' }]} testID="structured-mail-parcel">
<Truck size={14} color={c.textSecondary} />
<View style={[styles.pills, { flex: 1 }]}>
{parcel.carrier ? <Text style={[styles.metaText, styles.strong, { flex: 0 }]}>{parcel.carrier}</Text> : null}
{!parcel.carrier && parcel.trackingNumber ? (
<Text style={[styles.metaText, { flex: 0 }]}>{t('email_viewer.structured_mail.tracking_number', 'Tracking number')}</Text>
) : null}
{parcel.trackingNumber ? (
<CopyValueChip
value={parcel.trackingNumber}
copyLabel={t('email_viewer.structured_mail.copy_tracking_number', 'Copy tracking number {value}', { value: parcel.trackingNumber })}
copiedMessage={t('email_viewer.structured_mail.copied_tracking_number', 'Tracking number copied')}
/>
) : null}
</View>
</View>
) : null}
{expectedBy ? (
<MetaRow icon={Clock}>{t('email_viewer.structured_mail.expected_by', 'Arrives by {date}', { date: formatMomentDate(expectedBy, opts) })}</MetaRow>
) : 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 (
<CardShell
kind="shipment"
name={t('email_viewer.structured_mail.region_shipment', 'Order and delivery')}
icon={isParcel ? Truck : ShoppingBag}
cancelled={cancelled}
eyebrow={eyebrow}
title={title}
pills={shipment.status ? <OrderStatusPill status={shipment.status} /> : undefined}
actions={trackLinks.length > 0 || hasOrderLink ? (
<>
{trackLinks.map((link, i) => (
<CardLink key={`${link.url}-${i}`} link={link} label={t('email_viewer.structured_mail.link_track', 'Track')} icon={Truck} />
))}
<CardLink link={shipment.orderUrl} label={t('email_viewer.structured_mail.link_view_order', 'View order')} icon={ExternalLink} />
</>
) : undefined}
>
{shipment.orderNumber ? (
<View style={[styles.metaRow, { alignItems: 'center' }]} testID="structured-mail-order-number">
<ShoppingBag size={14} color={c.textSecondary} />
<View style={[styles.pills, { flex: 1 }]}>
<Text style={[styles.metaText, { flex: 0 }]}>{t('email_viewer.structured_mail.order_number', 'Order number')}</Text>
<CopyValueChip
value={shipment.orderNumber}
copyLabel={t('email_viewer.structured_mail.copy_order_number', 'Copy order number {value}', { value: shipment.orderNumber })}
copiedMessage={t('email_viewer.structured_mail.copied_order_number', 'Order number copied')}
/>
</View>
</View>
) : null}
{shown.length > 0 ? (
<View>
<MetaRow icon={Package}>
<Text style={styles.strong}>{shown.map((item) => item.name).join(', ')}</Text>
{more > 0 ? ` ${t('email_viewer.structured_mail.items_more', 'and {count} more', { count: more })}` : null}
</MetaRow>
{allowRemoteImages ? <ItemImages items={shown} /> : null}
</View>
) : null}
{summary ? <Text style={styles.summary} testID="structured-mail-summary">{summary}</Text> : null}
{shipment.parcels.map((parcel, i) => (
<ParcelRows key={`${parcel.trackingNumber ?? ''}-${i}`} parcel={parcel} showExpected={!ARRIVED_OR_STOPPED.has(shipment.status ?? 'processing')} />
))}
</CardShell>
);
}
@@ -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<StructuredCard[]>;
/** Registered sources. */
export const CARD_SOURCES: CardSource[] = [];
@@ -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 (
<View style={styles.list} testID="structured-mail-cards">
{cards.map((card, i) => {
const key = `${card.key}-${i}`;
switch (card.kind) {
case 'flight':
case 'train':
case 'bus':
return <TransportCard key={key} booking={card} calendarActions={calendarActions} />;
case 'lodging':
return <LodgingCard key={key} booking={card} calendarActions={calendarActions} />;
case 'rentalcar':
return <RentalCarCard key={key} booking={card} calendarActions={calendarActions} />;
case 'event':
return <EventCard key={key} booking={card} calendarActions={calendarActions} />;
case 'restaurant':
return <RestaurantCard key={key} booking={card} calendarActions={calendarActions} />;
case 'shipment':
return <ShipmentCard key={key} shipment={card} allowRemoteImages={allowRemoteImages} />;
default:
return null;
}
})}
</View>
);
}
@@ -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<Email, 'sentAt' | 'receivedAt'>): Date {
return new Date(email.sentAt ?? email.receivedAt);
}
async function resolveAll(cards: StructuredCard[]): Promise<StructuredCard[]> {
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;
}