From 71a1fad77bb952398f21ad1cfd7fa34a8a539f31 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:35:09 +0200 Subject: [PATCH] fix: keep a copied message's original date in the other account #1150 A copy between logins goes through `Email/import`, which sent no `receivedAt`, so the server stamped the import time and every copied message landed at the top of the destination folder as today's mail. `importRawEmail` now takes the original `receivedAt` (the same change #1151 carries), and the cross-account copy and move pass it. --- lib/__tests__/jmap-cross-account-move.test.ts | 19 +++++++++++++++++++ lib/jmap/client-interface.ts | 7 ++++++- lib/jmap/client.ts | 2 ++ .../email-store-cross-account-copy.test.ts | 5 ++++- stores/email-store.ts | 3 ++- 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/jmap-cross-account-move.test.ts b/lib/__tests__/jmap-cross-account-move.test.ts index 921a8422c..d16acf3e9 100644 --- a/lib/__tests__/jmap-cross-account-move.test.ts +++ b/lib/__tests__/jmap-cross-account-move.test.ts @@ -42,3 +42,22 @@ describe('copyEmailAcrossAccounts', () => { expect(calls.some(([m]) => m === 'Email/set')).toBe(false); }); }); + +describe('importRawEmail', () => { + it('keeps the original date of a message carried over from another account', async () => { + const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass'); + Object.assign(client, { accountId: 'me' }); + vi.spyOn(client, 'uploadBlob').mockResolvedValue({ blobId: 'b1' } as never); + const request = vi.spyOn(client as unknown as { request: (c: Call[]) => Promise }, 'request') + .mockResolvedValue({ methodResponses: [['Email/import', { created: { 'smime-import': { id: 'n1' } } }, '0']] }); + + await client.importRawEmail(new Blob(['raw']), { inbox: true }, { $seen: true }, undefined, '2024-03-01T10:00:00Z'); + const emails = (request.mock.calls[0][0][0][1] as { emails: Record> }).emails; + expect(emails['smime-import'].receivedAt).toBe('2024-03-01T10:00:00Z'); + + // Without a date the server stamps the import time, as before. + await client.importRawEmail(new Blob(['raw']), { inbox: true }); + const plain = (request.mock.calls[1][0][0][1] as { emails: Record> }).emails; + expect(plain['smime-import']).not.toHaveProperty('receivedAt'); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 3fb391ed7..49511495b 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -514,7 +514,12 @@ export interface IJMAPClient { copyFileNode(id: string, newName: string, parentId: string | null): Promise; // ── S/MIME raw-email helpers ────────────────────────────────── - importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record, accountId?: string): Promise; + /** + * Upload a raw message and import it (Email/import). `receivedAt` keeps the + * original date when a message is carried over from another account; + * without it the server stamps the import time. + */ + importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record, accountId?: string, receivedAt?: string): Promise; submitEmail(emailId: string, identityId: string): Promise; /** * Server-side move of one email across accounts reachable through THIS client diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index dcae4caa8..77e727f97 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -9074,6 +9074,7 @@ export class JMAPClient implements IJMAPClient { mailboxIds: Record, keywords?: Record, accountId?: string, + receivedAt?: string, ): Promise { const targetAccountId = accountId || this.accountId; // First upload the blob. Blob uploads are scoped to an account too — @@ -9091,6 +9092,7 @@ export class JMAPClient implements IJMAPClient { blobId, mailboxIds, keywords: keywords ?? { '$seen': true }, + ...(receivedAt ? { receivedAt } : {}), }, }, }, '0'], diff --git a/stores/__tests__/email-store-cross-account-copy.test.ts b/stores/__tests__/email-store-cross-account-copy.test.ts index 168921ba0..08e54e7db 100644 --- a/stores/__tests__/email-store-cross-account-copy.test.ts +++ b/stores/__tests__/email-store-cross-account-copy.test.ts @@ -56,7 +56,7 @@ describe('email-store crossAccountMoveEmails keepOriginal', () => { it('a move imports, deletes the source and drops it from the view', async () => { await useEmailStore.getState().crossAccountMoveEmails(new Map([['local-A', ['e1']]]), 'local-B', 'dest-inbox'); - expect(dest.importRawEmail).toHaveBeenCalledWith(expect.any(Blob), { 'dest-inbox': true }, { $seen: true }, undefined); + expect(dest.importRawEmail).toHaveBeenCalledWith(expect.any(Blob), { 'dest-inbox': true }, { $seen: true }, undefined, expect.any(String)); expect(source.deleteEmail).toHaveBeenCalledWith('e1', undefined); const state = useEmailStore.getState(); expect(state.emails.map((e) => e.id)).toEqual(['e2']); @@ -70,6 +70,9 @@ describe('email-store crossAccountMoveEmails keepOriginal', () => { ); expect(dest.importRawEmail).toHaveBeenCalledTimes(2); + // Each copy keeps its original date instead of arriving as today's mail. + const e1 = await source.getEmail.mock.results[0].value; + expect((dest.importRawEmail.mock.calls[0] as unknown[])[4]).toBe(e1.receivedAt); expect(source.deleteEmail).not.toHaveBeenCalled(); const state = useEmailStore.getState(); expect(state.emails.map((e) => e.id)).toEqual(['e1', 'e2']); diff --git a/stores/email-store.ts b/stores/email-store.ts index 6c554fbf1..6e2c46d84 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -2940,7 +2940,8 @@ export const useEmailStore = create((set, get) => ({ } const blob = await sourceClient.fetchBlob(full.blobId, undefined, undefined, sourceJmapAccountId); const keywords: Record = { ...(full.keywords ?? {}) }; - await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId); + // The original date, or the copy sorts as today's mail. + await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId, full.receivedAt); if (!keepOriginal) await sourceClient.deleteEmail(emailId, sourceJmapAccountId); return emailId; }),