From eb1859dbe3abdce02609a507547c74d32ef3c9b1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:10:59 +0100 Subject: [PATCH] feat: show booking, parcel and order cards in the reader --- .../__tests__/structured-mail-reader.test.tsx | 167 ++++++++++++++++++ components/email/email-viewer.tsx | 22 ++- components/email/thread-conversation-view.tsx | 12 ++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 components/email/__tests__/structured-mail-reader.test.tsx 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 ? (