feat: show booking, parcel and order cards in the reader

This commit is contained in:
Linus Rath
2026-10-30 05:10:59 +01:00
parent 972e5f6217
commit eb1859dbe3
3 changed files with 200 additions and 1 deletions
@@ -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 = `<html><head><script type="application/ld+json">${JSON.stringify(ORDER)}</script></head><body><p>Thanks for your order.</p></body></html>`;
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> = {}): 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(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
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<Variant>(['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(
<ConversationEmailBody email={email} allowExternal={allowExternal} onAllowExternal={() => {}} />,
);
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<Variant>(['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(
<ThreadConversationView
thread={{ threadId: 't1', emails: [newer, older], latestEmail: newer } as never}
emails={[newer, older]}
onBack={() => {}}
/>,
);
// 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);
});
+21 -1
View File
@@ -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) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="px-6 py-1.5">
<div className="flex flex-col gap-3 isolate">
@@ -5859,6 +5867,18 @@ export function EmailViewer({
<CalendarInvitationBanner email={email} />
</div>
)}
{/* Booking, parcel and order cards (structured mail) */}
{structuredCards.length > 0 && (
<div className="py-1">
<StructuredMailCards
cards={structuredCards}
email={email}
// Product images load only when the body's remote content would.
allowRemoteImages={!shouldBlockExternal && !plainView}
/>
</div>
)}
</div>
</div>
</div>
@@ -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({
</div>
)}
{structuredCards.length > 0 && (
// Aligned with the body: its own padding on the phone card, the
// caller's (className) in Mountain View.
<div style={bodyStyle ? { paddingTop: '0.5rem', paddingBottom: '0.25rem' } : { paddingInline: 'var(--density-card-p)', paddingTop: 'var(--density-card-p)' }}>
<StructuredMailCards cards={structuredCards} email={email} allowRemoteImages={allowExternal} />
</div>
)}
{/* Email Body */}
<div className="relative" style={bodyStyle ?? { padding: 'var(--density-card-p)' }}>
{emailContent.isHtml ? (