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.
This commit is contained in:
Linus Rath
2026-10-02 10:35:09 +02:00
parent 9ee463084d
commit 71a1fad77b
5 changed files with 33 additions and 3 deletions
@@ -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<unknown> }, '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<string, Record<string, unknown>> }).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<string, Record<string, unknown>> }).emails;
expect(plain['smime-import']).not.toHaveProperty('receivedAt');
});
});
+6 -1
View File
@@ -514,7 +514,12 @@ export interface IJMAPClient {
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
// ── S/MIME raw-email helpers ──────────────────────────────────
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
/**
* 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<string, boolean>, keywords?: Record<string, boolean>, accountId?: string, receivedAt?: string): Promise<string>;
submitEmail(emailId: string, identityId: string): Promise<void>;
/**
* Server-side move of one email across accounts reachable through THIS client
+2
View File
@@ -9074,6 +9074,7 @@ export class JMAPClient implements IJMAPClient {
mailboxIds: Record<string, boolean>,
keywords?: Record<string, boolean>,
accountId?: string,
receivedAt?: string,
): Promise<string> {
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'],
@@ -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']);
+2 -1
View File
@@ -2940,7 +2940,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
const blob = await sourceClient.fetchBlob(full.blobId, undefined, undefined, sourceJmapAccountId);
const keywords: Record<string, boolean> = { ...(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;
}),