Tags applied to a shared/group-mailbox message did not persist. Custom keywords (:*), and were written via Email/set against the reaching client's primary account instead of the email's owning account, so the server returned notUpdated without an error and the change was lost on the next reload. toggleStar already threaded an accountId through (#281); the keyword methods did not. Add an optional accountId to updateEmailKeywords and setKeyword and resolve it at the call sites from the email's source account (sourceClientAccountId / sourceAccountId), matching the existing delete/archive routing. Personal sources resolve to the account itself, so behavior there is unchanged.
75 lines
2.9 KiB
TypeScript
75 lines
2.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { JMAPClient } from '../jmap/client';
|
|
|
|
// Regression coverage for the shared-account keyword write path (#281): keyword
|
|
// mutations (tags, $answered/$forwarded) on a unified-inbox message must target
|
|
// the email's owning account, not the reaching client's primary. Writing to the
|
|
// primary account silently no-ops server-side (JMAP returns notUpdated without
|
|
// throwing), so the keyword is lost on the next reload. toggleStar already
|
|
// threaded accountId through; updateEmailKeywords/setKeyword did not.
|
|
|
|
function createClient(): JMAPClient {
|
|
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
|
Object.assign(client, {
|
|
apiUrl: 'https://jmap.example.com/api',
|
|
accountId: 'primary-account',
|
|
username: 'user@example.com',
|
|
});
|
|
return client;
|
|
}
|
|
|
|
interface JMAPMethodCall {
|
|
0: string;
|
|
1: Record<string, unknown>;
|
|
2: string;
|
|
}
|
|
|
|
function mockEmailSet() {
|
|
const captured: JMAPMethodCall[] = [];
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
fetchSpy.mockImplementation(async (_url, init) => {
|
|
const body = JSON.parse((init as { body: string }).body) as { methodCalls: JMAPMethodCall[] };
|
|
captured.push(...body.methodCalls);
|
|
return new Response(JSON.stringify({ methodResponses: [['Email/set', { updated: {} }, '0']] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
});
|
|
return { captured, fetchSpy };
|
|
}
|
|
|
|
describe('JMAP keyword writes route to the email account (#281)', () => {
|
|
beforeEach(() => vi.restoreAllMocks());
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
it('updateEmailKeywords sends the explicit accountId', async () => {
|
|
const client = createClient();
|
|
const { captured } = mockEmailSet();
|
|
await client.updateEmailKeywords('email-x', { '$label:work': true }, 'shared-account');
|
|
expect(captured[0][0]).toBe('Email/set');
|
|
expect(captured[0][1].accountId).toBe('shared-account');
|
|
});
|
|
|
|
it('updateEmailKeywords falls back to the primary account when none is given', async () => {
|
|
const client = createClient();
|
|
const { captured } = mockEmailSet();
|
|
await client.updateEmailKeywords('email-x', { '$label:work': true });
|
|
expect(captured[0][1].accountId).toBe('primary-account');
|
|
});
|
|
|
|
it('setKeyword sends the explicit accountId', async () => {
|
|
const client = createClient();
|
|
const { captured } = mockEmailSet();
|
|
await client.setKeyword('email-x', '$answered', 'shared-account');
|
|
expect(captured[0][0]).toBe('Email/set');
|
|
expect(captured[0][1].accountId).toBe('shared-account');
|
|
});
|
|
|
|
it('setKeyword falls back to the primary account when none is given', async () => {
|
|
const client = createClient();
|
|
const { captured } = mockEmailSet();
|
|
await client.setKeyword('email-x', '$answered');
|
|
expect(captured[0][1].accountId).toBe('primary-account');
|
|
});
|
|
});
|