diff --git a/components/email/__tests__/list-row-received-time.test.tsx b/components/email/__tests__/list-row-received-time.test.tsx new file mode 100644 index 000000000..d25f50b7c --- /dev/null +++ b/components/email/__tests__/list-row-received-time.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ThreadListItem } from '../thread-list-item'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useUIStore } from '@/stores/ui-store'; +import { groupEmailsByThread } from '@/lib/thread-utils'; +import { formatDate } from '@/lib/utils'; +import type { Email } from '@/lib/jmap/types'; + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { identities: [], username: null, client: null, activeAccountId: null, getClientForAccount: () => undefined }; + const useAuthStore = Object.assign( + (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + { getState: () => state }, + ); + return { useAuthStore }; +}); + +const makeEmail = (overrides: Partial = {}): Email => ({ + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: '2024-01-15T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Test Subject', + hasAttachment: false, + ...overrides, +}); + +function renderRow(emails: Email[]) { + const [thread] = groupEmailsByThread(emails); + return render( + {}} onEmailSelect={() => {}} />, + ); +} + +beforeEach(() => { + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + interfaceLayout: 'default', + timeZone: 'UTC', + }); + useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); +}); + +// The list sorts by arrival, so its rows show when the mail arrived (mail-17). +describe('list row time', () => { + it('shows when a delayed mail arrived, not its Date header', () => { + const sentAt = '2024-01-13T08:00:00Z'; + const receivedAt = '2024-01-15T10:00:00Z'; + renderRow([makeEmail({ sentAt, receivedAt })]); + + expect(screen.getByText(formatDate(receivedAt))).toBeInTheDocument(); + expect(screen.queryByText(formatDate(sentAt))).not.toBeInTheDocument(); + }); + + it('shows when the newest message of a conversation arrived', () => { + renderRow([ + makeEmail({ id: 'a', sentAt: '2024-01-10T08:00:00Z', receivedAt: '2024-01-10T08:01:00Z' }), + makeEmail({ id: 'b', sentAt: '2024-01-13T08:00:00Z', receivedAt: '2024-01-15T10:00:00Z' }), + ]); + + expect(screen.getByText(formatDate('2024-01-15T10:00:00Z'))).toBeInTheDocument(); + expect(screen.queryByText(formatDate('2024-01-13T08:00:00Z'))).not.toBeInTheDocument(); + }); + + it('keeps the Date header for imported mail that arrived long after it was sent (#891)', () => { + const sentAt = '2019-03-04T05:06:07Z'; + const receivedAt = '2024-01-15T10:00:00Z'; + renderRow([makeEmail({ sentAt, receivedAt })]); + + expect(screen.getByText(formatDate(sentAt))).toBeInTheDocument(); + }); +}); diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 89793f0e0..9aa4ae6d7 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback } from "react"; -import { emailDisplayDate } from "@/lib/email-date"; +import { emailListDate } from "@/lib/email-date"; import { useTranslations } from "next-intl"; import { cleanPreview, formatDate } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; @@ -116,7 +116,7 @@ export function ThreadEmailItem({ label: rowLabel([ isUnread && t('unread'), senderName, - formatDate(emailDisplayDate(email)), + formatDate(emailListDate(email)), isStarred && tStatus('starred'), isAnswered && tStatus('replied'), isForwarded && tStatus('forwarded'), @@ -215,7 +215,7 @@ export function ThreadEmailItem({ "text-xs tabular-nums", isUnread ? "text-foreground font-medium" : "text-muted-foreground" )}> - {formatDate(emailDisplayDate(email))} + {formatDate(emailListDate(email))} @@ -359,7 +359,7 @@ export function ThreadEmailItem({ ? "text-foreground font-medium" : "text-muted-foreground" )}> - {formatDate(emailDisplayDate(email))} + {formatDate(emailListDate(email))} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index c71be7eec..e86b199e1 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -3,7 +3,7 @@ import React, { useCallback } from "react"; import { cleanPreview, formatDate } from "@/lib/utils"; import { rowA11yProps, rowLabel } from "./list-a11y"; -import { emailDisplayDate } from "@/lib/email-date"; +import { emailListDate } from "@/lib/email-date"; import { formatScheduledSendAt } from "@/lib/scheduled-send-label"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; @@ -587,7 +587,7 @@ const SingleEmailItem = React.forwardRef( isUnread && t('unread'), sender?.name || (sender?.email && formatAddress(sender.email)) || tUnknown, email.subject || tNoSubject, - formatDate(emailDisplayDate(email)), + formatDate(emailListDate(email)), isPinned && tStatus('pinned'), isStarred && tStatus('starred'), isAnswered && tStatus('replied'), @@ -860,7 +860,7 @@ const SingleEmailItem = React.forwardRef( columns && LIST_COLUMN_CLASS.date, isUnread ? (mvRow ? 'text-foreground font-bold' : 'text-foreground font-semibold') : 'text-muted-foreground' )}> - {formatDate(emailDisplayDate(email), mvRow ? { listStyle: 'mountain-view' } : undefined)} + {formatDate(emailListDate(email), mvRow ? { listStyle: 'mountain-view' } : undefined)} )} {columns && } @@ -939,7 +939,7 @@ const SingleEmailItem = React.forwardRef( ? "text-foreground font-semibold" : "text-muted-foreground" )}> - {formatDate(emailDisplayDate(email))} + {formatDate(emailListDate(email))} )} {mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && ( @@ -1314,7 +1314,7 @@ export const ThreadListItem = React.forwardRef - {formatDate(emailDisplayDate(latestEmail), mvRow ? { listStyle: 'mountain-view' } : undefined)} + {formatDate(emailListDate(latestEmail), mvRow ? { listStyle: 'mountain-view' } : undefined)} )} {columns && sum + (e.size ?? 0), 0)} />} @@ -1718,7 +1718,7 @@ export const ThreadListItem = React.forwardRef - {formatDate(emailDisplayDate(latestEmail))} + {formatDate(emailListDate(latestEmail))} )} {mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && ( diff --git a/components/pro/pro-folder-tab-body.tsx b/components/pro/pro-folder-tab-body.tsx index 7277f30aa..85c234b05 100644 --- a/components/pro/pro-folder-tab-body.tsx +++ b/components/pro/pro-folder-tab-body.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { Folder, Loader2, Paperclip, RefreshCw, Star } from "@/components/icons"; import { cn, cleanPreview, formatDate } from "@/lib/utils"; -import { emailDisplayDate } from "@/lib/email-date"; +import { emailListDate } from "@/lib/email-date"; import { localizeMailboxName } from "@/lib/mailbox-label"; import { EmailViewer } from "@/components/email/email-viewer"; import { ProEmailView } from "@/components/pro/pro-email-tab-body"; @@ -255,7 +255,7 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) { {who} - {formatDate(emailDisplayDate(email))} + {formatDate(emailListDate(email))}
diff --git a/lib/__tests__/email-date.test.ts b/lib/__tests__/email-date.test.ts index 7fc4f527e..166c19d62 100644 --- a/lib/__tests__/email-date.test.ts +++ b/lib/__tests__/email-date.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { emailDisplayDate, MAX_FUTURE_SENT_AT_MS } from '@/lib/email-date'; +import { emailDisplayDate, emailListDate, MAX_DELIVERY_DELAY_MS, MAX_FUTURE_SENT_AT_MS } from '@/lib/email-date'; // #891: migrated/restored mail gets receivedAt = import time; the Date header // (sentAt) is the message's real date and must win for display. @@ -40,3 +40,23 @@ describe('emailDisplayDate', () => { expect(emailDisplayDate({})).toBeUndefined(); }); }); + +// mail-17: the list sorts by receivedAt, so it shows receivedAt too. +describe('emailListDate', () => { + const receivedAt = '2026-08-20T12:00:00Z'; + + it('shows when the mail arrived, not its Date header', () => { + expect(emailListDate({ sentAt: '2026-08-20T09:00:00Z', receivedAt })).toBe(receivedAt); + }); + + it('keeps the Date header for mail that arrived long after it was sent (imports, #891)', () => { + const sentAt = new Date(Date.parse(receivedAt) - MAX_DELIVERY_DELAY_MS - 1000).toISOString(); + expect(emailListDate({ sentAt, receivedAt })).toBe(sentAt); + }); + + it('falls back to the display date without a usable receivedAt', () => { + expect(emailListDate({ sentAt: '2019-03-04T05:06:07Z' })).toBe('2019-03-04T05:06:07Z'); + expect(emailListDate({ sentAt: '2019-03-04T05:06:07Z', receivedAt: 'garbage' })).toBe('2019-03-04T05:06:07Z'); + expect(emailListDate({})).toBeUndefined(); + }); +}); diff --git a/lib/email-date.ts b/lib/email-date.ts index 091dbe95b..a17ec9b83 100644 --- a/lib/email-date.ts +++ b/lib/email-date.ts @@ -41,3 +41,28 @@ export function emailDisplayDate(email: T): DisplayDate } return sentAt as DisplayDate; } + +/** + * How long after its `Date` header a message may arrive before the list takes + * it for imported or restored mail and shows the header instead. + */ +export const MAX_DELIVERY_DELAY_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * Which timestamp the message list shows: when the mail arrived, as Gmail and + * Outlook do. The list sorts by `receivedAt`, so a mail held up on the way + * would otherwise show an older time between newer ones and the list would + * look out of order. The reader and the details keep the sent time + * (`emailDisplayDate`). + * + * Mail that arrived much later than it was sent was imported or restored + * (#891): its `receivedAt` is the import time, so it shows its own date. + */ +export function emailListDate(email: T): DisplayDate { + const { sentAt, receivedAt } = email; + const received = receivedAt ? Date.parse(receivedAt) : NaN; + if (Number.isNaN(received)) return emailDisplayDate(email); + const sent = sentAt ? Date.parse(sentAt) : NaN; + if (!Number.isNaN(sent) && received - sent > MAX_DELIVERY_DELAY_MS) return emailDisplayDate(email); + return receivedAt as DisplayDate; +}