diff --git a/components/email/__tests__/structured-mail-reader.test.tsx b/components/email/__tests__/structured-mail-reader.test.tsx
new file mode 100644
index 000000000..77f49d069
--- /dev/null
+++ b/components/email/__tests__/structured-mail-reader.test.tsx
@@ -0,0 +1,167 @@
+// Booking, parcel and order cards in the reader: the single viewer (desktop
+// and Mountain View's open message) and the conversation body (phone thread,
+// Mountain View's other expanded messages). A card only for mail the
+// receiving server tied to its From domain.
+import { render, screen, within } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { parseAuthenticationResults } from '@/lib/email-headers';
+import type { Email, EmailBodyPart, Mailbox } from '@/lib/jmap/types';
+
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+}));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
+vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+const { ConversationEmailBody, ThreadConversationView } = await import('../thread-conversation-view');
+const { usePolicyStore } = await import('@/stores/policy-store');
+const { useSettingsStore } = await import('@/stores/settings-store');
+
+const ORDER = {
+ '@context': 'http://schema.org', '@type': 'Order', orderNumber: 'SO-77120', merchant: { '@type': 'Organization', name: 'Outdoor Shop' },
+ orderStatus: 'http://schema.org/OrderProcessing',
+ acceptedOffer: [{ '@type': 'Offer', itemOffered: { '@type': 'Product', name: 'Tent', image: 'https://img.shop.example/tent.jpg' } }],
+};
+const HTML = `
Thanks for your order.
`;
+const PASS = 'mx.example.org; dkim=pass header.d=shop.example; spf=pass smtp.mailfrom=bounce@shop.example; dmarc=pass header.from=shop.example';
+
+type Variant = 'trusted' | 'no-authentication-results' | 'not-on-receipt' | 'dkim-fail';
+
+function mail(id: string, variant: Variant = 'trusted', extra: Partial = {}): Email {
+ const ar = variant === 'dkim-fail' ? PASS.replace('dkim=pass', 'dkim=fail').replace('dmarc=pass', 'dmarc=fail') : PASS;
+ return {
+ id, blobId: `b-${id}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Outdoor Shop', email: 'orders@shop.example' }], to: [{ email: 'me@example.org' }],
+ subject: 'Your order', receivedAt: '2026-10-30T09:00:00Z', size: 100, preview: 'Thanks', hasAttachment: false,
+ ...(variant === 'no-authentication-results' ? {} : {
+ authenticationResults: parseAuthenticationResults([ar]),
+ authenticationResultsOnReceipt: variant !== 'not-on-receipt',
+ }),
+ htmlBody: [{ partId: '1', type: 'text/html' } as EmailBodyPart], textBody: [], attachments: [],
+ bodyValues: { '1': { value: HTML, isTruncated: false, isEncodingProblem: false } },
+ ...extra,
+ } as unknown as Email;
+}
+
+const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
+
+const initialPolicy = usePolicyStore.getState();
+function gate(on: boolean) {
+ usePolicyStore.setState((s) => ({ policy: { ...s.policy, features: { ...s.policy.features, structuredMailEnabled: on } } }));
+}
+const initialSettings = useSettingsStore.getState();
+afterEach(() => {
+ usePolicyStore.setState(initialPolicy, true);
+ useSettingsStore.setState(initialSettings, true);
+});
+
+const cardImages = () => cardRegion()?.querySelectorAll('img') ?? [];
+
+const cardRegion = () => screen.queryByRole('region', { name: 'region_shipment' });
+
+describe('cards in the single viewer', () => {
+ const viewer = (email: Email) => render();
+
+ it('shows the order card of a verified sender in the notification block', () => {
+ gate(true);
+ viewer(mail('v1'));
+ const region = cardRegion();
+ expect(region).not.toBeNull();
+ expect(within(region!).getByRole('heading', { name: 'Outdoor Shop' })).toBeInTheDocument();
+ expect(region).toHaveTextContent('SO-77120');
+ expect(screen.getAllByTestId('structured-mail-cards')).toHaveLength(1);
+ }, 30_000);
+
+ it.each(['no-authentication-results', 'not-on-receipt', 'dkim-fail'])('shows no card for a forged or unverified mail (%s)', (variant) => {
+ gate(true);
+ viewer(mail(`v-${variant}`, variant));
+ expect(screen.getByText('Your order')).toBeInTheDocument();
+ expect(cardRegion()).toBeNull();
+ }, 30_000);
+
+ it('loads item images only when the viewer loads remote content', () => {
+ gate(true);
+ expect(useSettingsStore.getState().externalContentPolicy).toBe('ask');
+ const first = viewer(mail('v3'));
+ expect(cardImages()).toHaveLength(0);
+ first.unmount();
+ useSettingsStore.setState({ externalContentPolicy: 'allow' });
+ viewer(mail('v4'));
+ expect(cardImages()).toHaveLength(1);
+ expect(cardImages()[0]).toHaveAttribute('src', 'https://img.shop.example/tent.jpg');
+ }, 30_000);
+
+ it('shows no card with the admin gate off', () => {
+ gate(false);
+ viewer(mail('v2'));
+ expect(cardRegion()).toBeNull();
+ }, 30_000);
+});
+
+describe('cards in the conversation body', () => {
+ const body = (email: Email, allowExternal = false) => render(
+ {}} />,
+ );
+
+ it('loads item images only when the message may load remote content', () => {
+ gate(true);
+ const first = body(mail('c2'));
+ expect(cardImages()).toHaveLength(0);
+ first.unmount();
+ body(mail('c3'), true);
+ expect(cardImages()).toHaveLength(1);
+ }, 30_000);
+
+ it('shows the card of a verified sender above the body', () => {
+ gate(true);
+ const { container } = body(mail('c1'));
+ const region = cardRegion();
+ expect(region).not.toBeNull();
+ expect(region).toHaveTextContent('SO-77120');
+ // Before the body's frame.
+ const iframe = container.querySelector('iframe');
+ expect(iframe).not.toBeNull();
+ expect(region!.compareDocumentPosition(iframe!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ }, 30_000);
+
+ it.each(['no-authentication-results', 'not-on-receipt', 'dkim-fail'])('shows no card for a forged or unverified mail (%s)', (variant) => {
+ gate(true);
+ body(mail(`c-${variant}`, variant));
+ expect(cardRegion()).toBeNull();
+ }, 30_000);
+
+ it('gives each expanded message its own card and none to a collapsed one', () => {
+ gate(true);
+ const newer = mail('t-new');
+ const older = mail('t-old', 'trusted', {
+ receivedAt: '2026-10-29T09:00:00Z',
+ bodyValues: { '1': { value: HTML.replace('SO-77120', 'SO-11111'), isTruncated: false, isEncodingProblem: false } },
+ });
+ render(
+ {}}
+ />,
+ );
+ // The newest message is expanded, the older (read) one collapsed.
+ const regions = screen.getAllByRole('region', { name: 'region_shipment' });
+ expect(regions).toHaveLength(1);
+ expect(regions[0]).toHaveTextContent('SO-77120');
+ expect(screen.queryByText('SO-11111')).toBeNull();
+ }, 30_000);
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index cc7d6537b..d2d950d0e 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -152,6 +152,8 @@ import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug";
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
import { VerificationCodeChip } from "./verification-code-chip";
+import { StructuredMailCards } from "./structured-mail/structured-mail-cards";
+import { useStructuredMail } from "./structured-mail/use-structured-mail";
import { ConversationEmailBody, type ConversationAttachmentHandler, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view";
import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -2700,6 +2702,11 @@ export function EmailViewer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, allowExternalContent, remoteBlockedByOrg]);
+ // Booking, parcel and order cards of the message itself (never of an
+ // unwrapped attached .eml); empty unless the sender is verified and the
+ // admin gate is on.
+ const structuredCards = useStructuredMail(email, blobSource);
+
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false, hasStyleTag: false, externalBlocked: false };
@@ -5760,7 +5767,8 @@ export function EmailViewer({
{(senderVerification ||
(!plainView && hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) ||
hasCalendarInvitation ||
- (readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
+ (readReceiptResponse === 'ask' && shouldOfferReadReceipt) ||
+ structuredCards.length > 0) && (
@@ -5859,6 +5867,18 @@ export function EmailViewer({
)}
+
+ {/* Booking, parcel and order cards (structured mail) */}
+ {structuredCards.length > 0 && (
+
+
+
+ )}
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index f078f8d06..71e8432e6 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -11,6 +11,8 @@ import { emailDisplayDate } from "@/lib/email-date";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
import { VerificationCodeChip } from "./verification-code-chip";
+import { StructuredMailCards } from "./structured-mail/structured-mail-cards";
+import { useStructuredMail } from "./structured-mail/use-structured-mail";
import { attachmentIcon } from "./attachment-chips";
import { ExternalSenderChip } from "./external-sender-chip";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
@@ -565,6 +567,8 @@ export function ConversationEmailBody({
const fallbackSource = useEmailStore(useShallow(() => resolveEmailBlobContext(email, client)));
const source = blobSource ?? fallbackSource;
const { client: blobClient, accountId } = source;
+ // Booking, parcel and order cards; only an expanded message mounts this body.
+ const structuredCards = useStructuredMail(email, source);
// Local CID URLs belong to a particular client/owner/message/part list.
// Hide the previous set during render, before the new fetch's effect runs.
const cidRequest = useMemo(() => ({ blobClient, accountId, emailId: email.id, attachments: email.attachments }),
@@ -929,6 +933,14 @@ export function ConversationEmailBody({
)}
+ {structuredCards.length > 0 && (
+ // Aligned with the body: its own padding on the phone card, the
+ // caller's (className) in Mountain View.
+
+
+
+ )}
+
{/* Email Body */}
{emailContent.isHtml ? (