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;
}