mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: show the mail's own date in the list and keep it on .eml import #891
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"><{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>
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') && (
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user