mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: show in the message list when mail arrived, the order it is sorted in
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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') && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user