feat: implement keyword migration functionality and update related components

This commit is contained in:
Linus Rath
2026-03-21 01:58:48 +01:00
parent 2547c10060
commit 2d56cc9be9
6 changed files with 139 additions and 31 deletions
+1
View File
@@ -72,6 +72,7 @@ export interface IJMAPClient {
batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>;
toggleStar(emailId: string, starred: boolean): Promise<void>;
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>;
+68 -17
View File
@@ -763,6 +763,56 @@ export class JMAPClient implements IJMAPClient {
]);
}
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
// Query all email IDs that have the old keyword
const allIds: string[] = [];
let position = 0;
const batchSize = 100;
// eslint-disable-next-line no-constant-condition
while (true) {
const response = await this.request([
["Email/query", {
accountId: this.accountId,
filter: { hasKeyword: oldKeyword },
limit: batchSize,
position,
}, "0"],
]);
const queryResult = response.methodResponses?.[0]?.[1];
const ids: string[] = queryResult?.ids || [];
allIds.push(...ids);
if (ids.length < batchSize) break;
position += ids.length;
}
if (allIds.length === 0) return 0;
// Batch update: remove old keyword, add new keyword using per-property patches
const updateBatchSize = 50;
for (let i = 0; i < allIds.length; i += updateBatchSize) {
const batch = allIds.slice(i, i + updateBatchSize);
const update: Record<string, Record<string, boolean | null>> = {};
for (const id of batch) {
update[id] = {
[`keywords/${oldKeyword}`]: null,
[`keywords/${newKeyword}`]: true,
};
}
await this.request([
["Email/set", {
accountId: this.accountId,
update,
}, "0"],
]);
}
return allIds.length;
}
async deleteEmail(emailId: string): Promise<void> {
await this.request([
["Email/set", {
@@ -1359,34 +1409,35 @@ export class JMAPClient implements IJMAPClient {
}));
}
// Destroy old draft before creating replacement to avoid duplicates
const methodCalls: JMAPMethodCall[] = [];
// Use a single Email/set call with both destroy and create for atomicity
const setArgs: Record<string, unknown> = {
accountId: this.accountId,
create: { [emailId]: emailData },
};
if (draftId) {
methodCalls.push(["Email/set", {
accountId: this.accountId, destroy: [draftId],
}, "0"]);
methodCalls.push(["Email/set", {
accountId: this.accountId, create: { [emailId]: emailData },
}, "1"]);
} else {
methodCalls.push(["Email/set", {
accountId: this.accountId, create: { [emailId]: emailData },
}, "0"]);
setArgs.destroy = [draftId];
}
const methodCalls: JMAPMethodCall[] = [
["Email/set", setArgs, "0"],
];
const response = await this.request(methodCalls);
const responseIndex = draftId ? 1 : 0;
if (response.methodResponses?.[responseIndex]?.[0] === "Email/set") {
const result = response.methodResponses[responseIndex][1];
if (response.methodResponses?.[0]?.[0] === "Email/set") {
const result = response.methodResponses[0][1];
if (result.notCreated || result.notUpdated) {
const errors = result.notCreated || result.notUpdated;
if (result.notCreated) {
const errors = result.notCreated;
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
console.error('Draft save error:', firstError);
throw new Error(firstError?.description || firstError?.type || 'Failed to save draft');
}
if (draftId && result.notDestroyed) {
console.warn('Failed to destroy old draft:', result.notDestroyed);
}
if (result.created?.[emailId]) {
return result.created[emailId].id;
}