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; +}