diff --git a/components/contacts/contact-activity.tsx b/components/contacts/contact-activity.tsx index 4a95ffcd2..3f818d3f7 100644 --- a/components/contacts/contact-activity.tsx +++ b/components/contacts/contact-activity.tsx @@ -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")} - {formatEmailDate(email.receivedAt)} + {formatEmailDate(emailDisplayDate(email))}
diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 90d21f827..f91eadd12 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -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(), 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( + {}} onEmailSelect={() => {}} />, + ); + expect(container.textContent).toMatch(/2019/); + expect(container.textContent).not.toMatch(/2024/); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 251c27709..dd0cc525a 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -711,7 +711,7 @@ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Ema {sender?.name || sender?.email || ''} - {compact ? formatDate(email.receivedAt) : mvSenderDate(email.receivedAt, timeFormat)} + {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} {cleanPreview(email.preview)} @@ -826,7 +826,7 @@ export function MvExpandedConversationCard({ <{sender.email}> )} - {compact ? formatDate(email.receivedAt) : mvSenderDate(email.receivedAt, timeFormat)} + {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 63738940d..e48819e45 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -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( '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)} )} @@ -766,7 +767,7 @@ const SingleEmailItem = React.forwardRef( ? "text-foreground font-semibold" : "text-muted-foreground" )}> - {formatDate(email.receivedAt)} + {formatDate(emailDisplayDate(email))} )} {mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && ( @@ -1289,7 +1290,7 @@ export const ThreadListItem = React.forwardRef - {formatDate(latestEmail.receivedAt, mvRow ? { listStyle: 'mountain-view' } : undefined)} + {formatDate(emailDisplayDate(latestEmail), mvRow ? { listStyle: 'mountain-view' } : undefined)} )} @@ -1374,7 +1375,7 @@ export const ThreadListItem = React.forwardRef - {formatDate(latestEmail.receivedAt)} + {formatDate(emailDisplayDate(latestEmail))} )} {mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && ( diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index d9b1b48f3..767bef433 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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++; diff --git a/components/pro/pro-folder-tab-body.tsx b/components/pro/pro-folder-tab-body.tsx index 3132e3ff2..4479ebbd9 100644 --- a/components/pro/pro-folder-tab-body.tsx +++ b/components/pro/pro-folder-tab-body.tsx @@ -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} - {formatDate(email.receivedAt)} + {formatDate(emailDisplayDate(email))}
diff --git a/lib/__tests__/eml-import.test.ts b/lib/__tests__/eml-import.test.ts index cf0b794ac..70dae52b4 100644 --- a/lib/__tests__/eml-import.test.ts +++ b/lib/__tests__/eml-import.test.ts @@ -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 ', + '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(); + }); +}); diff --git a/lib/eml-import.ts b/lib/eml-import.ts index 3056cd35c..dae322846 100644 --- a/lib/eml-import.ts +++ b/lib/eml-import.ts @@ -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 { + 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 { const { default: JSZip } = await import("jszip"); const zip = await JSZip.loadAsync(await file.arrayBuffer()); @@ -22,9 +51,11 @@ async function extractEmlsFromZip(file: File): Promise { 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; }