diff --git a/components/email/__tests__/viewer-details-per-message.test.tsx b/components/email/__tests__/viewer-details-per-message.test.tsx new file mode 100644 index 000000000..953d760bc --- /dev/null +++ b/components/email/__tests__/viewer-details-per-message.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// mail-7: "Show details" stayed open for every message opened after it. +// mail-29: a mail received before it was sent showed a delivery time. + +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 make = (id: string, sentAt: string, receivedAt: string) => ({ + id, blobId: `b-${id}`, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Sam', email: 'sam@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: `Message ${id}`, sentAt, receivedAt, preview: 'Hello', + hasAttachment: false, size: 100, textBody: [], htmlBody: [], attachments: [], bodyValues: {}, +}) as unknown as Email; + +const viewer = (email: Email) => ( + +); + +describe('message details panel', () => { + it('closes when another message opens', () => { + const first = make('e1', '2026-11-03T10:00:00Z', '2026-11-03T10:00:05Z'); + const second = make('e2', '2026-11-03T11:00:00Z', '2026-11-03T11:00:05Z'); + const { rerender } = render(viewer(first)); + act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); }); + expect(screen.queryByText('details.recipients_routing')).not.toBeNull(); + rerender(viewer(second)); + expect(screen.queryByText('details.recipients_routing')).toBeNull(); + // And back on the first one it is closed too: it was opened for that view. + rerender(viewer(first)); + expect(screen.queryByText('details.recipients_routing')).toBeNull(); + }); + + it('shows no delivery time for a mail received before it was sent', () => { + render(viewer(make('e3', '2026-11-03T21:40:00Z', '2026-11-03T20:40:00Z'))); + act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); }); + expect(screen.queryByText(/details\.delivery_time/i)).toBeNull(); + expect(document.body.textContent).not.toMatch(/delivery_time/); + }); + + it('still shows a real delivery time', () => { + render(viewer(make('e4', '2026-11-03T20:40:00Z', '2026-11-03T21:40:00Z'))); + act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); }); + expect(document.body.textContent).toMatch(/delivery_time/); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index b18c7c983..5d6ece619 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1670,7 +1670,13 @@ export function EmailViewer({ }, [onRescheduleScheduled, t, tComposer, scheduledSendAt, timeFormat]); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const { startTour } = useTour(); - const [showFullHeaders, setShowFullHeaders] = useState(false); + // The details panel belongs to the message it was opened on: the next + // message opens without it (mail-7). + const shownEmailId = email?.id ?? null; + const [details, setDetails] = useState<{ emailId: string | null; open: boolean }>({ emailId: shownEmailId, open: false }); + if (details.emailId !== shownEmailId) setDetails({ emailId: shownEmailId, open: false }); + const showFullHeaders = details.open && details.emailId === shownEmailId; + const setShowFullHeaders = (open: boolean) => setDetails({ emailId: shownEmailId, open }); const [isDownloadingAll, setIsDownloadingAll] = useState(false); const [imageThumbUrls, setImageThumbUrls] = useState>({}); const [allowExternalContent, setAllowExternalContent] = useState(false); @@ -5516,8 +5522,10 @@ ${printWatermark ? printWatermark.html : ''} }; const replyToDifferent = !!email.replyTo?.length && (!email.from || email.replyTo[0].email !== email.from[0]?.email); + // Received before it was sent means a wrong clock somewhere, not a + // delivery time (mail-29). const deliveryDeltaMs = email.sentAt && email.receivedAt - ? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime()) + ? new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime() : 0; const formatDelta = (diff: number) => { const minutes = Math.floor(diff / 60000);