feat: add message-list category tabs

This commit is contained in:
Linus Rath
2026-07-22 17:22:20 +02:00
parent 66bc10fa0f
commit 5105e000f5
15 changed files with 1158 additions and 11 deletions
+81 -4
View File
@@ -1173,16 +1173,27 @@ export class JMAPClient implements IJMAPClient {
}
}
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try {
const targetAccountId = accountId || this.accountId;
const filter: { inMailbox?: string; hasKeyword?: string } = {};
const simple: { inMailbox?: string; hasKeyword?: string } = {};
if (mailboxId) {
filter.inMailbox = mailboxId;
simple.inMailbox = mailboxId;
}
if (hasKeyword) {
filter.hasKeyword = hasKeyword;
simple.hasKeyword = hasKeyword;
}
// `extraFilter` is an arbitrary FilterCondition/FilterOperator ANDed
// into the view - the message-list category tabs' search contract.
const filter: Record<string, unknown> = extraFilter
? {
operator: "AND",
conditions: [
...(Object.keys(simple).length > 0 ? [simple] : []),
extraFilter,
],
}
: simple;
// Pinned-first uses the hasKeyword sort comparator (RFC 8621 §4.4.2);
// every page of a view must use the same sort or pagination tears.
const sort = pinnedFirst
@@ -1300,6 +1311,46 @@ export class JMAPClient implements IJMAPClient {
}
}
/**
* Per-tab unread counts for message-list category tabs. One Email/query
* (limit 0, calculateTotal) per tab, batched in a single request. Each
* entry's `filter` is the tab's resolved FilterCondition/FilterOperator
* (null = no extra condition, i.e. all unread in the mailbox).
*/
async getCategoryUnreadCounts(
mailboxId: string,
tabs: Array<{ id: string; filter: Record<string, unknown> | null }>,
accountId?: string,
): Promise<Record<string, number>> {
if (tabs.length === 0) return {};
const targetAccountId = accountId || this.accountId;
try {
const methodCalls: JMAPMethodCall[] = tabs.map((tab, i) => {
const conditions: Record<string, unknown>[] = [
{ inMailbox: mailboxId },
{ notKeyword: "$seen" },
];
if (tab.filter) conditions.push(tab.filter);
return ["Email/query", {
accountId: targetAccountId,
filter: { operator: "AND", conditions },
limit: 0,
calculateTotal: true,
}, `tab_${i}`];
});
const response = await this.request(methodCalls);
const result: Record<string, number> = {};
for (let i = 0; i < tabs.length; i++) {
result[tabs[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0;
}
return result;
} catch (error) {
console.error('Failed to get category tab counts:', error);
return {};
}
}
async getEmail(emailId: string, accountId?: string): Promise<Email | null> {
try {
const targetAccountId = accountId || this.accountId;
@@ -1458,6 +1509,32 @@ export class JMAPClient implements IJMAPClient {
]);
}
async removeKeyword(emailId: string, keyword: string, accountId?: string): Promise<void> {
await this.request([
["Email/set", {
accountId: accountId || this.accountId,
update: {
[emailId]: {
[`keywords/${keyword}`]: null,
},
},
}, "0"],
]);
}
/**
* Apply the same keyword PatchObject fragment to many messages in one
* Email/set. `patch` keys are `keywords/<name>` pointers with true (add)
* or null (remove) values - the category-tab move primitive.
*/
async batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void> {
if (emailIds.length === 0 || Object.keys(patch).length === 0) return;
const update = Object.fromEntries(emailIds.map(id => [id, { ...patch }]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
]);
}
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
// Query all email IDs that have the old keyword
const allIds: string[] = [];