mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { pointerTokenValue } from '@/lib/jmap/patch-pointer';
|
||||
|
||||
/**
|
||||
* Mock JMAP server for local development.
|
||||
@@ -1725,7 +1726,9 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
|
||||
// Patch-style keyword updates: "keywords/$seen", "keywords/$flagged", etc.
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (key.startsWith('keywords/')) {
|
||||
const keyword = key.slice('keywords/'.length);
|
||||
// The key is a JSON Pointer, so a nested tag arrives escaped
|
||||
// (`$label:work~1clients`) and has to be read back to its real name.
|
||||
const keyword = pointerTokenValue(key.slice('keywords/'.length));
|
||||
if (value) {
|
||||
email.keywords[keyword] = true;
|
||||
} else {
|
||||
|
||||
@@ -574,9 +574,15 @@ export function KeywordSettings() {
|
||||
try {
|
||||
const oldJmapKeyword = `$label:${oldId}`;
|
||||
const newJmapKeyword = `$label:${keyword.id}`;
|
||||
await client.migrateKeyword(oldJmapKeyword, newJmapKeyword);
|
||||
const migration = await client.migrateKeyword(oldJmapKeyword, newJmapKeyword);
|
||||
renameKeyword(oldId, keyword);
|
||||
fetchTagCounts(client);
|
||||
if (migration.refused > 0) {
|
||||
// Some mail kept the old keyword. The definition still follows the
|
||||
// messages that did move; the rest is what the warning is about.
|
||||
const toastModule = await import('sonner');
|
||||
toastModule.toast.warning(t("migration_error"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate keyword:", error);
|
||||
const toastModule = await import('sonner');
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import { keywordPointer, pointerToken, pointerTokenValue } from '../jmap/patch-pointer';
|
||||
|
||||
// A nested tag's id contains a slash (`work/clients`), so its keyword is
|
||||
// `$label:work/clients`. PatchObject keys are JSON Pointers (RFC 8620 section
|
||||
// 5.3), where an unescaped slash starts a new path segment - the server reads
|
||||
// `keywords/$label:work/clients` as "the `clients` member of the `$label:work`
|
||||
// keyword", which is not a boolean and is rejected or ignored. Nested tags can
|
||||
// therefore only be written with the slash escaped as `~1`.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers Email/query with one id and then nothing - the walk ends on an empty
|
||||
* page - and Email/set with a plain success.
|
||||
*/
|
||||
function mockJmap() {
|
||||
const captured: JMAPMethodCall[] = [];
|
||||
let queried = false;
|
||||
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);
|
||||
const methodResponses = body.methodCalls.map((call) => {
|
||||
if (call[0] !== 'Email/query') return ['Email/set', { updated: { m1: null } }, call[2]];
|
||||
const ids = queried ? [] : ['m1'];
|
||||
queried = true;
|
||||
return ['Email/query', { ids }, call[2]];
|
||||
});
|
||||
return new Response(JSON.stringify({ methodResponses }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
return { captured, fetchSpy };
|
||||
}
|
||||
|
||||
function patchOf(captured: JMAPMethodCall[], emailId = 'm1'): Record<string, unknown> {
|
||||
const set = captured.find((call) => call[0] === 'Email/set');
|
||||
const update = set?.[1].update as Record<string, Record<string, unknown>> | undefined;
|
||||
return update?.[emailId] ?? {};
|
||||
}
|
||||
|
||||
describe('pointerToken', () => {
|
||||
it('escapes a slash as ~1', () => {
|
||||
expect(pointerToken('work/clients')).toBe('work~1clients');
|
||||
});
|
||||
|
||||
it('escapes a tilde as ~0, and does so before slashes', () => {
|
||||
expect(pointerToken('a~/b')).toBe('a~0~1b');
|
||||
expect(pointerToken('a~1b')).toBe('a~01b');
|
||||
});
|
||||
|
||||
it('leaves a token with nothing to escape alone', () => {
|
||||
expect(keywordPointer('$label:red')).toBe('keywords/$label:red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointerTokenValue', () => {
|
||||
it('reads back what pointerToken wrote', () => {
|
||||
for (const value of ['work/clients', 'a~/b', 'a~1b', '$label:red', '~0/~1']) {
|
||||
expect(pointerTokenValue(pointerToken(value))).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads ~1 before ~0, so an escaped tilde stays literal', () => {
|
||||
expect(pointerTokenValue('~01')).toBe('~1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyword writes escape the JSON Pointer', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('setKeyword escapes a nested tag keyword', async () => {
|
||||
const { captured } = mockJmap();
|
||||
await createClient().setKeyword('m1', '$label:work/clients');
|
||||
expect(patchOf(captured)).toEqual({ 'keywords/$label:work~1clients': true });
|
||||
});
|
||||
|
||||
it('removeKeyword escapes a nested tag keyword', async () => {
|
||||
const { captured } = mockJmap();
|
||||
await createClient().removeKeyword('m1', '$label:work/clients');
|
||||
expect(patchOf(captured)).toEqual({ 'keywords/$label:work~1clients': null });
|
||||
});
|
||||
|
||||
it('migrateKeyword escapes both the old and the new keyword', async () => {
|
||||
const { captured } = mockJmap();
|
||||
await createClient().migrateKeyword('$label:work/clients', '$label:work/acme');
|
||||
expect(patchOf(captured)).toEqual({
|
||||
'keywords/$label:work~1clients': null,
|
||||
'keywords/$label:work~1acme': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a flat keyword unchanged', async () => {
|
||||
const { captured } = mockJmap();
|
||||
await createClient().setKeyword('m1', '$label:red');
|
||||
expect(patchOf(captured)).toEqual({ 'keywords/$label:red': true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateKeyword reports a server that refused', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
/**
|
||||
* Answers queries from `pages` in order (an empty page ends the walk) and
|
||||
* every Email/set with `setResult`. A page given as an error tuple is
|
||||
* returned as the method response itself.
|
||||
*/
|
||||
function mockRun(
|
||||
pages: (string[] | ['error', Record<string, unknown>])[],
|
||||
setResult: Record<string, unknown> | ['error', Record<string, unknown>] = { updated: {} },
|
||||
) {
|
||||
let page = 0;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
const body = JSON.parse((init as { body: string }).body) as { methodCalls: JMAPMethodCall[] };
|
||||
const methodResponses = body.methodCalls.map((call) => {
|
||||
if (call[0] === 'Email/query') {
|
||||
const next = pages[Math.min(page, pages.length - 1)];
|
||||
page++;
|
||||
if (Array.isArray(next) && next[0] === 'error') {
|
||||
return ['error', next[1] as Record<string, unknown>, call[2]];
|
||||
}
|
||||
return ['Email/query', { ids: next as string[] }, call[2]];
|
||||
}
|
||||
if (Array.isArray(setResult) && setResult[0] === 'error') {
|
||||
return ['error', setResult[1] as Record<string, unknown>, call[2]];
|
||||
}
|
||||
return ['Email/set', setResult as Record<string, unknown>, call[2]];
|
||||
});
|
||||
return new Response(JSON.stringify({ methodResponses }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('throws when the query for tagged mail fails instead of reading it as none', async () => {
|
||||
mockRun([['error', { type: 'serverFail', description: 'boom' }]]);
|
||||
await expect(createClient().migrateKeyword('$label:red', '$label:iso')).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('throws when the update fails outright', async () => {
|
||||
mockRun([['m1'], []], ['error', { type: 'serverFail', description: 'no writes today' }]);
|
||||
await expect(createClient().migrateKeyword('$label:red', '$label:iso')).rejects.toThrow(
|
||||
'no writes today',
|
||||
);
|
||||
});
|
||||
|
||||
it('counts only the messages the server actually updated', async () => {
|
||||
mockRun([['m1', 'm2'], []], { updated: { m1: null }, notUpdated: { m2: { type: 'notFound' } } });
|
||||
await expect(createClient().migrateKeyword('$label:red', '$label:iso')).resolves.toEqual({
|
||||
migrated: 1,
|
||||
refused: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a message refused for a reason other than being gone', async () => {
|
||||
mockRun([['m1', 'm2'], []], { updated: { m1: null }, notUpdated: { m2: { type: 'forbidden' } } });
|
||||
// Throwing would abandon m1, which this same call already migrated.
|
||||
await expect(createClient().migrateKeyword('$label:red', '$label:iso')).resolves.toEqual({
|
||||
migrated: 1,
|
||||
refused: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps what it migrated when a later batch fails outright', async () => {
|
||||
// One message per Email/set, so the first write lands before the second
|
||||
// fails: throwing would strand m1 under the new keyword with the tag
|
||||
// definition still on the old one.
|
||||
const client = createClient();
|
||||
Object.assign(client, { capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInSet: 1 } } });
|
||||
let set = 0;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
const body = JSON.parse((init as { body: string }).body) as { methodCalls: JMAPMethodCall[] };
|
||||
const methodResponses = body.methodCalls.map((call) => {
|
||||
if (call[0] === 'Email/query') {
|
||||
return ['Email/query', { ids: set === 0 ? ['m1', 'm2'] : [], queryState: 's1' }, call[2]];
|
||||
}
|
||||
set++;
|
||||
return set === 1
|
||||
? ['Email/set', { updated: { m1: null } }, call[2]]
|
||||
: ['error', { type: 'serverFail', description: 'gave up' }, call[2]];
|
||||
});
|
||||
return new Response(JSON.stringify({ methodResponses }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
await expect(client.migrateKeyword('$label:red', '$label:iso')).resolves.toEqual({
|
||||
migrated: 1,
|
||||
refused: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when nothing at all could be migrated', async () => {
|
||||
mockRun([['m1'], []], { notUpdated: { m1: { type: 'forbidden' } } });
|
||||
await expect(createClient().migrateKeyword('$label:red', '$label:iso')).rejects.toThrow(
|
||||
'forbidden',
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts the walk when the query state changes under it', async () => {
|
||||
// Positions only line up within one queryState. The first page belongs to a
|
||||
// state that is gone by the next request; a walk that kept its position (and
|
||||
// the ids it had collected) would migrate mail the tag is no longer on and
|
||||
// miss the mail it is.
|
||||
const current = ['m1', 'm2'];
|
||||
let queries = 0;
|
||||
const touched: string[] = [];
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
const body = JSON.parse((init as { body: string }).body) as { methodCalls: JMAPMethodCall[] };
|
||||
const methodResponses = body.methodCalls.map((call) => {
|
||||
if (call[0] === 'Email/query') {
|
||||
queries++;
|
||||
if (queries === 1) {
|
||||
return ['Email/query', { ids: ['stale1', 'stale2'], queryState: 's1' }, call[2]];
|
||||
}
|
||||
const position = call[1].position as number;
|
||||
const limit = call[1].limit as number;
|
||||
return ['Email/query', { ids: current.slice(position, position + limit), queryState: 's2' }, call[2]];
|
||||
}
|
||||
const update = call[1].update as Record<string, unknown>;
|
||||
touched.push(...Object.keys(update));
|
||||
return ['Email/set', { updated: Object.fromEntries(Object.keys(update).map((id) => [id, null])) }, call[2]];
|
||||
});
|
||||
return new Response(JSON.stringify({ methodResponses }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
await expect(createClient().migrateKeyword('$label:red', '$label:iso')).resolves.toEqual({
|
||||
migrated: 2,
|
||||
refused: 0,
|
||||
});
|
||||
expect(touched).toEqual(['m1', 'm2']);
|
||||
});
|
||||
|
||||
it('keeps migrating later batches after one message is refused', async () => {
|
||||
// A refusal is per message: the mail behind it in the queue is unaffected,
|
||||
// and skipping it would leave far more behind than the server refused.
|
||||
const client = createClient();
|
||||
Object.assign(client, { capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInSet: 1 } } });
|
||||
const touched: string[] = [];
|
||||
let queried = false;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
const body = JSON.parse((init as { body: string }).body) as { methodCalls: JMAPMethodCall[] };
|
||||
const methodResponses = body.methodCalls.map((call) => {
|
||||
if (call[0] === 'Email/query') {
|
||||
const ids = queried ? [] : ['m1', 'm2'];
|
||||
queried = true;
|
||||
return ['Email/query', { ids, queryState: 's1' }, call[2]];
|
||||
}
|
||||
const id = Object.keys(call[1].update as Record<string, unknown>)[0];
|
||||
touched.push(id);
|
||||
return id === 'm1'
|
||||
? ['Email/set', { notUpdated: { m1: { type: 'forbidden' } } }, call[2]]
|
||||
: ['Email/set', { updated: { [id]: null } }, call[2]];
|
||||
});
|
||||
return new Response(JSON.stringify({ methodResponses }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
await expect(client.migrateKeyword('$label:red', '$label:iso')).resolves.toEqual({
|
||||
migrated: 1,
|
||||
refused: 1,
|
||||
});
|
||||
expect(touched).toEqual(['m1', 'm2']);
|
||||
});
|
||||
|
||||
it('gives up before writing anything when the mailbox will not hold still', async () => {
|
||||
// Each query reports a new state, so no page ever belongs with the last.
|
||||
// Mixing them would retag mail the tag has since been taken off.
|
||||
let queries = 0;
|
||||
const sets: string[] = [];
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
const body = JSON.parse((init as { body: string }).body) as { methodCalls: JMAPMethodCall[] };
|
||||
const methodResponses = body.methodCalls.map((call) => {
|
||||
if (call[0] === 'Email/query') {
|
||||
queries++;
|
||||
return ['Email/query', { ids: [`m${queries}`], queryState: `s${queries}` }, call[2]];
|
||||
}
|
||||
sets.push(call[2]);
|
||||
return ['Email/set', { updated: {} }, call[2]];
|
||||
});
|
||||
return new Response(JSON.stringify({ methodResponses }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
await expect(createClient().migrateKeyword('$label:red', '$label:iso')).rejects.toThrow(
|
||||
/kept changing/,
|
||||
);
|
||||
expect(sets).toEqual([]);
|
||||
});
|
||||
|
||||
it('ends the walk when a page repeats ids instead of paginating', async () => {
|
||||
// A server that answers every position with the same page would otherwise
|
||||
// be paged forever.
|
||||
mockRun([['m1']]);
|
||||
await expect(createClient().migrateKeyword('$label:red', '$label:iso')).resolves.toEqual({
|
||||
migrated: 1,
|
||||
refused: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps paging when the server returns fewer ids than the limit asked for', async () => {
|
||||
// RFC 8620 section 5.5 lets a server clamp the limit. Treating a short page
|
||||
// as the last one would silently migrate only the first page.
|
||||
const pages = [['m1', 'm2'], ['m3'], []];
|
||||
let query = 0;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
const body = JSON.parse((init as { body: string }).body) as { methodCalls: JMAPMethodCall[] };
|
||||
const methodResponses = body.methodCalls.map((call) => {
|
||||
if (call[0] === 'Email/query') {
|
||||
const ids = pages[Math.min(query, pages.length - 1)];
|
||||
query++;
|
||||
return ['Email/query', { ids }, call[2]];
|
||||
}
|
||||
const update = call[1].update as Record<string, unknown>;
|
||||
const updated = Object.fromEntries(Object.keys(update).map((id) => [id, null]));
|
||||
return ['Email/set', { updated }, call[2]];
|
||||
});
|
||||
return new Response(JSON.stringify({ methodResponses }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
await expect(createClient().migrateKeyword('$label:red', '$label:iso')).resolves.toEqual({
|
||||
migrated: 3,
|
||||
refused: 0,
|
||||
});
|
||||
expect(query).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IJMAPClient, KeywordDiscoveryResult } from '@/lib/jmap/client-interface';
|
||||
import type { IJMAPClient, KeywordDiscoveryResult, KeywordMigration } from '@/lib/jmap/client-interface';
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types';
|
||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||
import { getDemoData, type DemoData } from './demo-data';
|
||||
@@ -382,16 +382,16 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
||||
let count = 0;
|
||||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<KeywordMigration> {
|
||||
let migrated = 0;
|
||||
for (const email of this.data.emails) {
|
||||
if (email.keywords[oldKeyword]) {
|
||||
delete email.keywords[oldKeyword];
|
||||
email.keywords[newKeyword] = true;
|
||||
count++;
|
||||
migrated++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
return { migrated, refused: 0 };
|
||||
}
|
||||
|
||||
async deleteEmail(emailId: string): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
/** What `migrateKeyword` managed to do. */
|
||||
export interface KeywordMigration {
|
||||
/** Messages now carrying the new keyword. */
|
||||
migrated: number;
|
||||
/**
|
||||
* Messages the server refused to update for a reason other than being gone.
|
||||
* Those still carry the old keyword.
|
||||
*/
|
||||
refused: number;
|
||||
}
|
||||
|
||||
export interface KeywordInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -153,7 +164,13 @@ export interface IJMAPClient {
|
||||
removeKeyword(emailId: string, keyword: string, accountId?: string): Promise<void>;
|
||||
/** Apply one `keywords/<name>` patch fragment (true=add, null=remove) to many messages in a single Email/set. */
|
||||
batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void>;
|
||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
||||
/**
|
||||
* Rewrite one keyword to another on every message that carries it, in
|
||||
* batches. Fails outright only when the server refuses the whole call;
|
||||
* per-message refusals are reported in the result, since the messages
|
||||
* migrated alongside them stay migrated.
|
||||
*/
|
||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<KeywordMigration>;
|
||||
deleteEmail(emailId: string, accountId?: string): Promise<void>;
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void>;
|
||||
|
||||
+92
-13
@@ -1,8 +1,9 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient, KeywordDiscoveryResult, KeywordInfo } from "./client-interface";
|
||||
import type { IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
import { batched, itemsPerRequest } from "./request-limits";
|
||||
import { keywordPointer } from "./patch-pointer";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
||||
import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection";
|
||||
@@ -1756,7 +1757,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
accountId: accountId || this.accountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
[`keywords/${keyword}`]: true,
|
||||
[keywordPointer(keyword)]: true,
|
||||
},
|
||||
},
|
||||
}, "0"],
|
||||
@@ -1769,7 +1770,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
accountId: accountId || this.accountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
[`keywords/${keyword}`]: null,
|
||||
[keywordPointer(keyword)]: null,
|
||||
},
|
||||
},
|
||||
}, "0"],
|
||||
@@ -1791,11 +1792,18 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
||||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<KeywordMigration> {
|
||||
// Query all email IDs that have the old keyword
|
||||
const seen = new Set<string>();
|
||||
const allIds: string[] = [];
|
||||
let position = 0;
|
||||
const batchSize = 100;
|
||||
// Positions only line up within one queryState (RFC 8620 section 5.5).
|
||||
// Mail arriving mid-walk shifts every later page, so a changed state
|
||||
// restarts the walk - bounded, since a busy mailbox could change forever.
|
||||
let queryState: string | undefined;
|
||||
let restarts = 0;
|
||||
const maxRestarts = 3;
|
||||
|
||||
while (true) {
|
||||
const response = await this.request([
|
||||
@@ -1807,35 +1815,106 @@ export class JMAPClient implements IJMAPClient {
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
const queryResult = response.methodResponses?.[0]?.[1];
|
||||
const ids: string[] = queryResult?.ids || [];
|
||||
allIds.push(...ids);
|
||||
const [queryMethod, queryResult] = response.methodResponses?.[0] || [];
|
||||
if (queryMethod === "error") {
|
||||
// Reading a refusal as "no messages carry the tag" would report a
|
||||
// clean migration of mail that still has the old keyword.
|
||||
throw new Error(queryResult?.description || "Failed to list emails with the keyword");
|
||||
}
|
||||
const state = queryResult?.queryState as string | undefined;
|
||||
if (queryState === undefined) {
|
||||
queryState = state;
|
||||
} else if (state !== undefined && state !== queryState) {
|
||||
if (restarts >= maxRestarts) {
|
||||
// Carrying on would mix ids from states that no longer agree, and
|
||||
// retagging a message the tag has since been taken off is worse than
|
||||
// not retagging at all. Nothing has been written yet, so this is a
|
||||
// clean failure the caller can simply repeat.
|
||||
throw new Error("The mailbox kept changing while listing the messages to retag");
|
||||
}
|
||||
restarts++;
|
||||
queryState = state;
|
||||
seen.clear();
|
||||
allIds.length = 0;
|
||||
position = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ids.length < batchSize) break;
|
||||
const ids: string[] = queryResult?.ids || [];
|
||||
const fresh = ids.filter(id => !seen.has(id));
|
||||
for (const id of fresh) seen.add(id);
|
||||
allIds.push(...fresh);
|
||||
|
||||
// A server may return fewer ids than the limit asked for (RFC 8620
|
||||
// section 5.5 lets it clamp), so a short page does not end the walk.
|
||||
// A page carrying nothing new under an unchanged state does: the walk
|
||||
// is not advancing, and continuing would loop for as long as the server
|
||||
// keeps answering. Position counts the server's own page, not the ids
|
||||
// this walk had not seen before.
|
||||
if (fresh.length === 0) break;
|
||||
position += ids.length;
|
||||
}
|
||||
|
||||
if (allIds.length === 0) return 0;
|
||||
if (allIds.length === 0) return { migrated: 0, refused: 0 };
|
||||
|
||||
// Batch update: remove old keyword, add new keyword using per-property patches
|
||||
let migrated = 0;
|
||||
let refusals = 0;
|
||||
// A whole batch the server refused to take: it will not take the next one
|
||||
// either. A single message it refused: the rest of the mail is unaffected,
|
||||
// and only the reason is worth keeping.
|
||||
let stopped: Error | null = null;
|
||||
let refusal: Error | null = null;
|
||||
for (const batch of batched(allIds, this.getMaxObjectsInSet())) {
|
||||
if (stopped) {
|
||||
refusals += batch.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const update: Record<string, Record<string, boolean | null>> = {};
|
||||
for (const id of batch) {
|
||||
update[id] = {
|
||||
[`keywords/${oldKeyword}`]: null,
|
||||
[`keywords/${newKeyword}`]: true,
|
||||
[keywordPointer(oldKeyword)]: null,
|
||||
[keywordPointer(newKeyword)]: true,
|
||||
};
|
||||
}
|
||||
|
||||
await this.request([
|
||||
const response = await this.request([
|
||||
["Email/set", {
|
||||
accountId: this.accountId,
|
||||
update,
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
const [setMethod, setResult] = response.methodResponses?.[0] || [];
|
||||
if (setMethod === "error") {
|
||||
stopped = new Error(setResult?.description || "Failed to migrate keyword");
|
||||
refusals += batch.length;
|
||||
continue;
|
||||
}
|
||||
// Email/set applies per message, so a refusal is never all-or-nothing:
|
||||
// throwing here would abandon messages this very call already migrated.
|
||||
// Report instead - a message deleted since the query (notFound) is
|
||||
// nothing to report, any other refusal leaves the old keyword in place
|
||||
// on mail the caller would otherwise treat as migrated.
|
||||
const notUpdated = (setResult?.notUpdated || {}) as Record<string, { type?: string; description?: string }>;
|
||||
const failed = Object.entries(notUpdated);
|
||||
const refused = failed.filter(([, error]) => error?.type !== 'notFound');
|
||||
if (refused.length > 0) {
|
||||
debug.warn('keywords', 'Keyword migration refused for', refused.length, 'message(s):', refused[0][1]);
|
||||
refusal = refusal || new Error(refused[0][1]?.description || refused[0][1]?.type || "Failed to migrate keyword");
|
||||
}
|
||||
refusals += refused.length;
|
||||
migrated += batch.length - failed.length;
|
||||
}
|
||||
|
||||
return allIds.length;
|
||||
// Nothing moved: the caller must not rename the tag over messages that all
|
||||
// kept the old keyword. Something moved: that is a partial migration the
|
||||
// caller reports, not an error - the messages that did move are migrated
|
||||
// and renaming the definition is what keeps them named.
|
||||
if (migrated === 0 && (stopped || refusal)) throw (stopped || refusal) as Error;
|
||||
|
||||
return { migrated, refused: refusals };
|
||||
}
|
||||
|
||||
async deleteEmail(emailId: string, accountId?: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Naming one member of an object in a JMAP PatchObject.
|
||||
*
|
||||
* A PatchObject key is a JSON Pointer (RFC 8620 section 5.3), so a slash in the
|
||||
* member's own name starts a new path segment unless it is escaped. That bites
|
||||
* keywords: a nested tag is stored as `$label:work/clients`, and the unescaped
|
||||
* pointer `keywords/$label:work/clients` asks for the `clients` member of the
|
||||
* `$label:work` keyword - not a boolean, so the patch is rejected or ignored
|
||||
* and the tag never lands.
|
||||
*/
|
||||
|
||||
/** One JSON Pointer reference token: `~` -> `~0`, `/` -> `~1` (RFC 6901). */
|
||||
export function pointerToken(value: string): string {
|
||||
return value.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
}
|
||||
|
||||
/**
|
||||
* The member name a reference token stands for: the inverse of
|
||||
* `pointerToken`. `~1` is read before `~0`, per RFC 6901 - the other order
|
||||
* would turn the escaped `~1` of `~01` back into a slash.
|
||||
*/
|
||||
export function pointerTokenValue(token: string): string {
|
||||
return token.replace(/~1/g, '/').replace(/~0/g, '~');
|
||||
}
|
||||
|
||||
/** The PatchObject key that sets or clears the keyword `keyword`. */
|
||||
export function keywordPointer(keyword: string): string {
|
||||
return `keywords/${pointerToken(keyword)}`;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { Email } from '@/lib/jmap/types';
|
||||
import type { MessageListTab, MessageListTabsConfig } from '@/lib/plugin-types';
|
||||
import { MAX_MESSAGE_LIST_TABS } from '@/lib/plugin-types';
|
||||
import { messageListTabHooks } from '@/lib/plugin-hooks';
|
||||
import { keywordPointer } from '@/lib/jmap/patch-pointer';
|
||||
import { useEmailStore } from './email-store';
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────
|
||||
@@ -296,8 +297,8 @@ export const useMessageListTabsStore = create<MessageListTabsStore>()((set, get)
|
||||
// One PatchObject per message: drop every other category keyword, add the
|
||||
// target's (removals of absent keywords are no-ops per RFC 8620 §5.3).
|
||||
const patch: Record<string, boolean | null> = {};
|
||||
for (const k of keywordsRemoved) patch[`keywords/${k}`] = null;
|
||||
if (keywordAdded) patch[`keywords/${keywordAdded}`] = true;
|
||||
for (const k of keywordsRemoved) patch[keywordPointer(k)] = null;
|
||||
if (keywordAdded) patch[keywordPointer(keywordAdded)] = true;
|
||||
|
||||
// Group by owning account so shared-mailbox messages patch correctly.
|
||||
const byAccount = new Map<string | undefined, string[]>();
|
||||
|
||||
Reference in New Issue
Block a user