From 53eb91ab0ef35b2c0f0ddd90d4e47f938619df29 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 05:58:47 +0100
Subject: [PATCH] fix: keep the message details panel readable in a narrow
reader, with addresses, dates and sign-in results that fit
---
.../__tests__/viewer-details-fit.test.tsx | 84 +++++++++++++++++++
components/email/email-viewer.tsx | 51 ++++++-----
components/email/recipient-popover.tsx | 6 +-
components/ui/breakable-address.tsx | 17 ++++
lib/__tests__/text-breaks.test.ts | 27 ++++++
lib/text-breaks.ts | 34 ++++++++
6 files changed, 198 insertions(+), 21 deletions(-)
create mode 100644 components/email/__tests__/viewer-details-fit.test.tsx
create mode 100644 components/ui/breakable-address.tsx
create mode 100644 lib/__tests__/text-breaks.test.ts
create mode 100644 lib/text-breaks.ts
diff --git a/components/email/__tests__/viewer-details-fit.test.tsx b/components/email/__tests__/viewer-details-fit.test.tsx
new file mode 100644
index 000000000..580262959
--- /dev/null
+++ b/components/email/__tests__/viewer-details-fit.test.tsx
@@ -0,0 +1,84 @@
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+// The message details panel in a narrow reader: the two columns followed the
+// window's width, not the panel's, so they stayed side by side in a narrow pane;
+// the sender's address went letter by letter down a sliver of a column, dates took
+// three lines, and long sign-in results ran off the side.
+
+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() }),
+}));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+
+const mailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+
+const ADDRESS = 'no-reply-x8@notifications.mail.example-service.example.net';
+const SPF_DOMAIN = 'bounces+31859940-0dcc@em9672.notifications.example-service.example.net';
+
+const email = {
+ id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Example Service', email: ADDRESS }], to: [{ name: 'Bob', email: 'bob@example.com' }],
+ subject: 'Hello', sentAt: '2026-11-04T04:33:50Z', receivedAt: '2026-11-04T04:33:53Z', preview: 'Hello',
+ hasAttachment: false, size: 100, textBody: [], htmlBody: [], attachments: [], bodyValues: {},
+ authenticationResults: { spf: { result: 'pass', domain: SPF_DOMAIN } },
+} as unknown as Email;
+
+function openDetails() {
+ render();
+ act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); });
+ const heading = screen.getByText('details.recipients_routing');
+ return heading.closest('section')!;
+}
+
+describe('message details panel', () => {
+ it('lays out by its own width, not the window', () => {
+ const routing = openDetails();
+ const grid = routing.parentElement!;
+ expect(grid.className).toMatch(/@3xl:grid-cols-2/);
+ expect(grid.className).not.toMatch(/\blg:grid-cols-2/);
+ expect(grid.parentElement!.className).toMatch(/@container/);
+ // The value column may shrink below its content's width.
+ expect(routing.querySelector('dl')!.className).toContain('minmax(0,1fr)');
+ });
+
+ it('shows the sender name and address apart, the address breakable at its dots', () => {
+ const routing = openDetails();
+ expect(routing.textContent).not.toContain(`<${ADDRESS}>`);
+ const address = routing.querySelector(`[title="${ADDRESS}"]`)!;
+ expect(address.textContent).toBe(ADDRESS);
+ expect(address.querySelectorAll('wbr').length).toBeGreaterThan(2);
+ });
+
+ it('shows dates short, with the full date on hover', () => {
+ const routing = openDetails();
+ const times = routing.querySelectorAll('time');
+ expect(times.length).toBe(2);
+ for (const time of Array.from(times)) {
+ expect(time.getAttribute('title')!.length).toBeGreaterThan(time.textContent!.length);
+ }
+ });
+
+ it('keeps sign-in result chips inside the panel, the long part cut with the whole on hover', () => {
+ openDetails();
+ const domain = screen.getByText(SPF_DOMAIN);
+ expect(domain.className).toContain('truncate');
+ expect(domain.getAttribute('title')).toBe(SPF_DOMAIN);
+ expect(domain.parentElement!.className).toContain('max-w-full');
+ });
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 0b1b5fac7..eb050d200 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -155,6 +155,7 @@ import { useTour } from "@/components/tour/tour-provider";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover";
+import { BreakableAddress } from "@/components/ui/breakable-address";
import { mentionFromLink } from "@/lib/recipient-mentions";
import { MailtoLink } from "@/components/ui/mailto-link";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
@@ -5413,6 +5414,11 @@ ${printWatermark ? printWatermark.html : ''}
const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: '-';
+ // A long date wraps over three lines in the narrow column: the short one
+ // shows, the full one is on hover.
+ const shownDate = (iso?: string) => iso
+ ?
+ : '-';
const auth = email.authenticationResults;
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
const topMimeType = email.bodyStructure?.type;
@@ -5423,7 +5429,7 @@ ${printWatermark ? printWatermark.html : ''}
);
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
<>
-
{label}
+ {label}
- {name}
-
+ {name}
+
{translateAuthResult(result)}
{extra && (
<>
·
- {extra}
+ {extra}
>
)}
@@ -5478,17 +5484,24 @@ ${printWatermark ? printWatermark.html : ''}
);
return (
-
-
+
+
{t('details.recipients_routing')}
-
+
` : undefined}
+ label={sender?.name && sender?.email ? (
+ <>
+ {sender.name}
+
+
+
+ >
+ ) : undefined}
onViewContact={handleViewContactSidebar}
className="text-sm text-start"
/>
@@ -5542,10 +5555,10 @@ ${printWatermark ? printWatermark.html : ''}
)}
{email.sentAt && (
- {fullDate(email.sentAt)}
+ {shownDate(email.sentAt)}
)}
- {fullDate(email.receivedAt)}
+ {shownDate(email.receivedAt)}
{deliveryDeltaMs > 60000 && (
· {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}
)}
@@ -5590,7 +5603,7 @@ ${printWatermark ? printWatermark.html : ''}
)}
{email.spamScore !== undefined && (
5 ? "bg-red-500/[0.07] border-red-500/30" :
email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" :
"bg-green-500/[0.07] border-green-500/30",
@@ -5613,7 +5626,7 @@ ${printWatermark ? printWatermark.html : ''}
{email.spamStatus && (
<>
·
- {email.spamStatus}
+ {email.spamStatus}
>
)}
@@ -5644,9 +5657,9 @@ ${printWatermark ? printWatermark.html : ''}
{hasIdentifiers && (
{t('details.identifiers_threading')}
-
+
{email.messageId && (
- {email.messageId}
+
)}
{email.inReplyTo && email.inReplyTo.length > 0 && (
@@ -5678,7 +5691,7 @@ ${printWatermark ? printWatermark.html : ''}
{t('details.message_properties')}
-
+
{email.subject !== undefined && (
{email.subject || {t('details.no_subject')}}
)}
@@ -5701,9 +5714,9 @@ ${printWatermark ? printWatermark.html : ''}
{hasListInfo && (
-
+
{t('details.mailing_list')}
-
+
{listHeaders?.listId && (
{listHeaders.listId}
)}
@@ -5730,7 +5743,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Plugin-supplied category. Plugins whose shouldShow accepts
`category === null` render their own titled section here. */}
{hasDetailsSlotOffers && (
-
+
)}
diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx
index caaf8bc78..1ea559311 100644
--- a/components/email/recipient-popover.tsx
+++ b/components/email/recipient-popover.tsx
@@ -251,12 +251,14 @@ interface RecipientPopoverProps {
email: string;
/** Display label override (e.g. "me") */
displayLabel?: string;
+ /** Rich content in place of the text label, such as a name over its address. */
+ label?: React.ReactNode;
/** Called when user clicks "View contact" - receives the contact and email */
onViewContact?: (contact: ContactCard | null, email: string) => void;
className?: string;
}
-export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) {
+export function RecipientPopover({ name, email, displayLabel, label, onViewContact, className }: RecipientPopoverProps) {
const [anchor, setAnchor] = useState(null);
const triggerRef = useRef(null);
const shownEmail = useOwnDomainAddress()(email);
@@ -280,7 +282,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
className
)}
>
- {displayLabel || name || shownEmail}
+ {label ?? {displayLabel || name || shownEmail}}
{anchor && (
diff --git a/components/ui/breakable-address.tsx b/components/ui/breakable-address.tsx
new file mode 100644
index 000000000..c5e703132
--- /dev/null
+++ b/components/ui/breakable-address.tsx
@@ -0,0 +1,17 @@
+import { Fragment } from "react";
+import { addressBreakPieces } from "@/lib/text-breaks";
+
+/** An address that wraps at its dots and after its @, not anywhere (lib/text-breaks.ts). */
+export function BreakableAddress({ text }: { text: string }) {
+ const pieces = addressBreakPieces(text);
+ return (
+ <>
+ {pieces.map((piece, index) => (
+
+ {index > 0 && }
+ {piece}
+
+ ))}
+ >
+ );
+}
diff --git a/lib/__tests__/text-breaks.test.ts b/lib/__tests__/text-breaks.test.ts
new file mode 100644
index 000000000..4224f665d
--- /dev/null
+++ b/lib/__tests__/text-breaks.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { addressBreakPieces } from '../text-breaks';
+
+describe('addressBreakPieces', () => {
+ it('breaks after the @ and before dots', () => {
+ expect(addressBreakPieces('dana.with.long@subdomain.example.net')).toEqual(['dana', '.with', '.long@', 'subdomain', '.example.net']);
+ });
+
+ it('breaks after + - and _', () => {
+ expect(addressBreakPieces('bounces+318-0dcc_x@em.example.org')).toEqual(['bounces+', '318-', '0dcc_', 'x@em', '.example.org']);
+ });
+
+ it('keeps short runs whole and loses no character', () => {
+ const text = 'a.b.c@d.io';
+ const pieces = addressBreakPieces(text);
+ expect(pieces.join('')).toBe(text);
+ expect(pieces.every((piece) => piece.length >= 3)).toBe(true);
+ });
+
+ it('never leaves the top-level domain alone on a line', () => {
+ expect(addressBreakPieces('someone@notifications.mail.example-service.example.net').at(-1)).toBe('.example.net');
+ });
+
+ it('leaves a name without break points as one piece', () => {
+ expect(addressBreakPieces('Anthropic')).toEqual(['Anthropic']);
+ });
+});
diff --git a/lib/text-breaks.ts b/lib/text-breaks.ts
new file mode 100644
index 000000000..2bb86de85
--- /dev/null
+++ b/lib/text-breaks.ts
@@ -0,0 +1,34 @@
+/**
+ * Where a long address may wrap: after "@", "+", "-" and "_", and before a dot, as
+ * printed addresses do. A browser on its own wraps such a string anywhere once it
+ * doesn't fit, which leaves ragged two-letter lines in a narrow column.
+ *
+ * Returns the pieces; a line break opportunity () goes between each two.
+ * Pieces are never shorter than MIN_PIECE characters: "a.b.c" stays whole. The
+ * last few characters (".net") stay with the piece before them.
+ */
+const MIN_PIECE = 3;
+const MAX_TAIL = 4;
+
+export function addressBreakPieces(text: string): string[] {
+ const pieces: string[] = [];
+ let current = '';
+ for (const ch of text) {
+ if (ch === '.' && current.length >= MIN_PIECE) {
+ pieces.push(current);
+ current = ch;
+ continue;
+ }
+ current += ch;
+ if ((ch === '@' || ch === '+' || ch === '-' || ch === '_') && current.length >= MIN_PIECE) {
+ pieces.push(current);
+ current = '';
+ }
+ }
+ if (current) {
+ // A short tail, such as ".net", joins the piece before it: no line holds only that.
+ if (current.length <= MAX_TAIL && pieces.length) pieces[pieces.length - 1] += current;
+ else pieces.push(current);
+ }
+ return pieces;
+}