fix: show the mail's own date in the list and keep it on .eml import #891

This commit is contained in:
Linus Rath
2026-10-17 19:17:58 +02:00
parent 4a69a7df8a
commit cbf917372c
10 changed files with 128 additions and 17 deletions
+2 -1
View File
@@ -9,6 +9,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { Avatar } from "@/components/ui/avatar";
import { cleanPreview } from "@/lib/utils";
import { emailDisplayDate } from "@/lib/email-date";
import { Section } from "./contact-detail";
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
@@ -239,7 +240,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
{sender.name || t("unknown_sender")}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatEmailDate(email.receivedAt)}
{formatEmailDate(emailDisplayDate(email))}
</span>
</div>
<div className="text-sm truncate">
@@ -646,3 +646,39 @@ describe('ThreadListItem prefetch on mouse press', () => {
expect(onPrefetch).not.toHaveBeenCalled();
});
});
describe('ThreadListItem date (#891)', () => {
// A migrated mailbox: the server stamped every message with the import day,
// but the Date header still carries when the mail was really sent.
const imported = { receivedAt: '2024-01-15T10:00:00Z', sentAt: '2019-03-05T08:30:00Z' };
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
interfaceLayout: 'default',
dateFormat: 'full',
});
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox' });
});
it('shows the Date header, not the import time, on a single-message row', () => {
const { container } = renderRow(makeEmail(imported));
expect(container.textContent).toMatch(/2019/);
expect(container.textContent).not.toMatch(/2024/);
});
it('shows the Date header of the latest message on a thread row', () => {
const emails = [
makeEmail({ id: 'a', receivedAt: '2024-01-15T09:00:00Z', sentAt: '2019-03-04T08:30:00Z' }),
makeEmail({ id: 'b', ...imported }),
];
const [thread] = groupEmailsByThread(emails);
const { container } = render(
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} />,
);
expect(container.textContent).toMatch(/2019/);
expect(container.textContent).not.toMatch(/2024/);
});
});
+4 -4
View File
@@ -711,7 +711,7 @@ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Ema
{sender?.name || sender?.email || ''}
</span>
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
{compact ? formatDate(email.receivedAt) : mvSenderDate(email.receivedAt, timeFormat)}
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
</span>
</span>
<span className="block text-sm text-muted-foreground truncate">{cleanPreview(email.preview)}</span>
@@ -826,7 +826,7 @@ export function MvExpandedConversationCard({
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
{compact ? formatDate(email.receivedAt) : mvSenderDate(email.receivedAt, timeFormat)}
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
</span>
</span>
<button
@@ -3205,9 +3205,9 @@ export function EmailViewer({
let imported = 0;
let failed = 0;
for (const { blob } of emails) {
for (const { blob, receivedAt } of emails) {
try {
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true });
await client.importRawEmail(blob, { [mailboxId]: true }, { '$seen': true }, undefined, receivedAt);
imported++;
} catch {
failed++;
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallba
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizeEmailBodyForIframe, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { emailDisplayDate } from "@/lib/email-date";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
import { VerificationCodeChip } from "./verification-code-chip";
@@ -383,7 +384,7 @@ function EmailCard({
)}
</div>
<div className="text-sm text-muted-foreground">
{formatDate(email.receivedAt)}
{formatDate(emailDisplayDate(email))}
</div>
{!isExpanded && density !== 'extra-compact' && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback } from "react";
import { emailDisplayDate } from "@/lib/email-date";
import { useTranslations } from "next-intl";
import { cleanPreview, formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
@@ -221,7 +222,7 @@ export function ThreadEmailItem({
? "text-foreground font-medium"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
{formatDate(emailDisplayDate(email))}
</span>
</div>
</div>
+5 -4
View File
@@ -2,6 +2,7 @@
import React, { useCallback } from "react";
import { cleanPreview, formatDate } from "@/lib/utils";
import { emailDisplayDate } from "@/lib/email-date";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
@@ -688,7 +689,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
'text-xs tabular-nums',
isUnread ? (mvRow ? 'text-foreground font-bold' : 'text-foreground font-semibold') : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt, mvRow ? { listStyle: 'mountain-view' } : undefined)}
{formatDate(emailDisplayDate(email), mvRow ? { listStyle: 'mountain-view' } : undefined)}
</span>
)}
</div>
@@ -766,7 +767,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
{formatDate(emailDisplayDate(email))}
</span>
)}
{mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && (
@@ -1289,7 +1290,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
'text-xs tabular-nums',
hasUnread ? (mvRow ? 'text-foreground font-bold' : 'text-foreground font-semibold') : 'text-muted-foreground'
)}>
{formatDate(latestEmail.receivedAt, mvRow ? { listStyle: 'mountain-view' } : undefined)}
{formatDate(emailDisplayDate(latestEmail), mvRow ? { listStyle: 'mountain-view' } : undefined)}
</span>
)}
</div>
@@ -1374,7 +1375,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
{formatDate(emailDisplayDate(latestEmail))}
</span>
)}
{mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && (
+2 -2
View File
@@ -3450,9 +3450,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
let imported = 0;
let failed = 0;
for (const { blob } of emails) {
for (const { blob, receivedAt } of emails) {
try {
await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true });
await client.importRawEmail(blob, { [targetMailboxId]: true }, { '$seen': true }, undefined, receivedAt);
imported++;
} catch {
failed++;
+2 -1
View File
@@ -4,6 +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 { localizeMailboxName } from "@/lib/mailbox-label";
import { EmailViewer } from "@/components/email/email-viewer";
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
@@ -235,7 +236,7 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
{who}
</span>
<span className="flex-shrink-0 text-xs text-muted-foreground">
{formatDate(email.receivedAt)}
{formatDate(emailDisplayDate(email))}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5">
+40 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import JSZip from 'jszip';
import { expandImportableEmails, EML_IMPORT_ACCEPT } from '@/lib/eml-import';
import { expandImportableEmails, readMessageDate, EML_IMPORT_ACCEPT } from '@/lib/eml-import';
const emlFile = (name: string, content = 'raw email', type = 'message/rfc822') =>
new File([content], name, { type });
@@ -49,3 +49,42 @@ describe('expandImportableEmails', () => {
expect(EML_IMPORT_ACCEPT).toBe('.eml,.zip,message/rfc822,application/zip');
});
});
describe('Date header as receivedAt (#891)', () => {
const CRLF = String.fromCharCode(13, 10);
const raw = [
'From: Alice <alice@example.com>',
'Subject: old mail',
'Date: Tue, 5 Mar 2019 09:30:00',
' +0100 (CET)',
'',
'Date: Wed, 1 Jan 2025 00:00:00 +0000',
'body',
].join(CRLF);
it('carries the Date header of an .eml file as a JMAP UTCDate', async () => {
const out = await expandImportableEmails([emlFile('old.eml', raw)]);
expect(out[0].receivedAt).toBe('2019-03-05T08:30:00Z');
});
it('carries the Date header of each .eml inside a .zip', async () => {
const zip = new JSZip();
zip.file('old.eml', raw);
const blob = await zip.generateAsync({ type: 'blob' });
const out = await expandImportableEmails([new File([blob], 'a.zip', { type: 'application/zip' })]);
expect(out[0].receivedAt).toBe('2019-03-05T08:30:00Z');
});
it('leaves receivedAt unset when there is no usable Date header', async () => {
const bodyOnly = ['Subject: x', '', 'Date: Tue, 5 Mar 2019 09:30:00 +0100'].join(CRLF);
await expect(readMessageDate(new Blob([bodyOnly]))).resolves.toBeUndefined();
const garbage = ['Date: not a date', '', 'body'].join(CRLF);
await expect(readMessageDate(new Blob([garbage]))).resolves.toBeUndefined();
});
it('ignores a Date header far in the future', async () => {
const now = Date.parse('2026-01-01T00:00:00Z');
const future = ['Date: Fri, 1 Jan 2027 00:00:00 +0000', '', 'body'].join(CRLF);
await expect(readMessageDate(new Blob([future]), now)).resolves.toBeUndefined();
});
});
+33 -2
View File
@@ -1,6 +1,14 @@
import { MAX_FUTURE_SENT_AT_MS } from "./email-date";
export interface ImportableEmail {
name: string;
blob: Blob;
/**
* The message's `Date` header as a JMAP UTCDate, passed as `receivedAt` on
* Email/import. Without it the server stamps the import time and every
* imported message looks like it arrived today (#891).
*/
receivedAt?: string;
}
const EMAIL_MIME = "message/rfc822";
@@ -13,6 +21,27 @@ function isZipName(name: string): boolean {
return /\.zip$/i.test(name);
}
/** Only the header block is needed; don't decode a whole large message. */
const HEADER_SCAN_BYTES = 64 * 1024;
/**
* Read the `Date` header of a raw RFC 5322 message and return it as a JMAP
* UTCDate (no fractional seconds). Returns undefined when the header is
* missing, unparsable, or more than a day in the future.
*/
export async function readMessageDate(blob: Blob, now: number = Date.now()): Promise<string | undefined> {
const head = await blob.slice(0, HEADER_SCAN_BYTES).text();
const end = head.search(/\r?\n\r?\n/);
const headers = (end === -1 ? head : head.slice(0, end)).replace(/\r?\n[ \t]+/g, " ");
const match = /^Date:[ \t]*(.+)$/im.exec(headers);
if (!match) return undefined;
// Drop RFC 5322 comments such as "(CEST)" before parsing.
const value = match[1].replace(/\([^)]*\)/g, " ").trim();
const time = Date.parse(value);
if (Number.isNaN(time) || time - now > MAX_FUTURE_SENT_AT_MS) return undefined;
return new Date(time).toISOString().replace(/\.\d{3}Z$/, "Z");
}
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
const { default: JSZip } = await import("jszip");
const zip = await JSZip.loadAsync(await file.arrayBuffer());
@@ -22,9 +51,11 @@ async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
if (entry.dir) continue;
if (!isEmlName(entry.name)) continue;
const data = await entry.async("arraybuffer");
const blob = new Blob([data], { type: EMAIL_MIME });
out.push({
name: entry.name.split(/[\\/]/).pop() || entry.name,
blob: new Blob([data], { type: EMAIL_MIME }),
blob,
receivedAt: await readMessageDate(blob),
});
}
return out;
@@ -40,7 +71,7 @@ export async function expandImportableEmails(
continue;
}
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
out.push({ name: file.name, blob });
out.push({ name: file.name, blob, receivedAt: await readMessageDate(blob) });
}
return out;
}