fix: show in the message list when mail arrived, the order it is sorted in

This commit is contained in:
Linus Rath
2026-11-04 06:42:43 +01:00
parent 0e400dbcf4
commit e67705add0
6 changed files with 143 additions and 14 deletions
@@ -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> = {}): 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(
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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<string>(), 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();
});
});
+4 -4
View File
@@ -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))}
</span>
<SizeCell bytes={email.size} />
</div>
@@ -359,7 +359,7 @@ export function ThreadEmailItem({
? "text-foreground font-medium"
: "text-muted-foreground"
)}>
{formatDate(emailDisplayDate(email))}
{formatDate(emailListDate(email))}
</span>
</div>
</div>
+7 -7
View File
@@ -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<HTMLDivElement, SingleEmailItemProps>(
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<HTMLDivElement, SingleEmailItemProps>(
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)}
</span>
)}
{columns && <SizeCell bytes={email.size} />}
@@ -939,7 +939,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(emailDisplayDate(email))}
{formatDate(emailListDate(email))}
</span>
)}
{mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && (
@@ -1314,7 +1314,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
displayNames.join(', '),
threadSubject,
t('messages_tooltip', { count: emailCount }),
formatDate(emailDisplayDate(latestEmail)),
formatDate(emailListDate(latestEmail)),
hasPinned && tStatus('pinned'),
hasStarred && tStatus('starred'),
hasAnswered && tStatus('replied'),
@@ -1631,7 +1631,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
columns && LIST_COLUMN_CLASS.date,
hasUnread ? (mvRow ? 'text-foreground font-bold' : 'text-foreground font-semibold') : 'text-muted-foreground'
)}>
{formatDate(emailDisplayDate(latestEmail), mvRow ? { listStyle: 'mountain-view' } : undefined)}
{formatDate(emailListDate(latestEmail), mvRow ? { listStyle: 'mountain-view' } : undefined)}
</span>
)}
{columns && <SizeCell bytes={thread.emails.reduce((sum, e) => sum + (e.size ?? 0), 0)} />}
@@ -1718,7 +1718,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(emailDisplayDate(latestEmail))}
{formatDate(emailListDate(latestEmail))}
</span>
)}
{mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && (
+2 -2
View File
@@ -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}
</span>
<span className="flex-shrink-0 text-xs text-muted-foreground">
{formatDate(emailDisplayDate(email))}
{formatDate(emailListDate(email))}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5">
+21 -1
View File
@@ -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();
});
});
+25
View File
@@ -41,3 +41,28 @@ export function emailDisplayDate<T extends DatedEmail>(email: T): DisplayDate<T>
}
return sentAt as DisplayDate<T>;
}
/**
* 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<T extends DatedEmail>(email: T): DisplayDate<T> {
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<T>;
}