From 3c9fa5dc25e2ccace11036e8ee09c4709f415bd7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:05:38 +0200 Subject: [PATCH] feat: implement batch archiving of emails --- components/email/email-list.tsx | 11 ++-- lib/demo/demo-client.ts | 29 +++++++++ lib/jmap/client-interface.ts | 7 +++ lib/jmap/client.ts | 107 ++++++++++++++++++++++++++++++++ stores/email-store.ts | 38 ++++++++++++ 5 files changed, 187 insertions(+), 5 deletions(-) diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 8ac0ccf5..605672ad 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -69,6 +69,7 @@ export function EmailList({ batchMarkAsRead, batchDelete, batchMoveToMailbox, + batchArchive, batchMarkAsSpam, batchUndoSpam, loadMoreEmails, @@ -497,12 +498,12 @@ export function EmailList({ onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={() => client && batchDelete(client)} onBatchArchive={async () => { - if (!onArchive) return; - const selected = emails.filter((e) => selectedEmailIds.has(e.id)); - for (const email of selected) { - await onArchive(email); + if (!client) return; + try { + await batchArchive(client); + } catch (error) { + console.error('Failed to batch archive:', error); } - clearSelection(); }} onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)} onBatchMarkAsSpam={async () => { diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index ad25b307..b312952a 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -269,6 +269,35 @@ export class DemoJMAPClient implements IJMAPClient { this.recalcMailboxCounts(); } + async batchArchiveEmails( + emails: Array<{ id: string; receivedAt: string }>, + archiveMailboxId: string, + mode: 'single' | 'year' | 'month', + ): Promise { + if (emails.length === 0) return; + if (mode === 'single') { + await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId); + return; + } + for (const { id, receivedAt } of emails) { + const email = this.data.emails.find(e => e.id === id); + if (!email) continue; + const d = new Date(receivedAt); + const year = d.getFullYear().toString(); + const month = (d.getMonth() + 1).toString().padStart(2, '0'); + let yearBox = this.data.mailboxes.find(m => m.name === year && m.parentId === archiveMailboxId); + if (!yearBox) yearBox = await this.createMailbox(year, archiveMailboxId); + let destId = yearBox.id; + if (mode === 'month') { + let monthBox = this.data.mailboxes.find(m => m.name === month && m.parentId === yearBox!.id); + if (!monthBox) monthBox = await this.createMailbox(month, yearBox.id); + destId = monthBox.id; + } + email.mailboxIds = { [destId]: true }; + } + this.recalcMailboxCounts(); + } + async moveEmail(emailId: string, toMailboxId: string): Promise { const email = this.data.emails.find(e => e.id === emailId); if (email) email.mailboxIds = { [toMailboxId]: true }; diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 916b7d8b..d09767bc 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -85,6 +85,13 @@ export interface IJMAPClient { moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise; batchDeleteEmails(emailIds: string[]): Promise; batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise; + batchArchiveEmails( + emails: Array<{ id: string; receivedAt: string }>, + archiveMailboxId: string, + mode: 'single' | 'year' | 'month', + existingMailboxes: Mailbox[], + accountId?: string, + ): Promise; moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise; emptyMailbox(mailboxId: string): Promise; markAsSpam(emailId: string, accountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 0a0685e7..4fb5ac8b 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1207,6 +1207,113 @@ export class JMAPClient implements IJMAPClient { ]); } + async batchArchiveEmails( + emails: Array<{ id: string; receivedAt: string }>, + archiveMailboxId: string, + mode: 'single' | 'year' | 'month', + existingMailboxes: Mailbox[], + accountId?: string, + ): Promise { + if (emails.length === 0) return; + const targetAccountId = accountId || this.accountId; + + if (mode === 'single') { + await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId, targetAccountId); + return; + } + + type Dest = { year: string; month?: string }; + const destFor = new Map(); + for (const e of emails) { + const d = new Date(e.receivedAt); + const year = d.getFullYear().toString(); + const month = (d.getMonth() + 1).toString().padStart(2, '0'); + destFor.set(e.id, mode === 'year' ? { year } : { year, month }); + } + + // Resolve each destination folder to either an existing id or a creation-id reference ("#"). + const yearIdFor = new Map(); + const monthIdFor = new Map(); + const createEntries: Record> = {}; + + const findExisting = (name: string, parentId: string) => + existingMailboxes.find(m => + m.accountId === targetAccountId && + m.name === name && + (m.parentId === parentId || m.parentId === (parentId.startsWith('#') ? undefined : parentId)), + ); + + for (const dest of destFor.values()) { + if (!yearIdFor.has(dest.year)) { + const existing = findExisting(dest.year, archiveMailboxId); + if (existing) { + yearIdFor.set(dest.year, existing.originalId || existing.id); + } else { + const cid = `year-${dest.year}`; + createEntries[cid] = { name: dest.year, parentId: archiveMailboxId }; + yearIdFor.set(dest.year, `#${cid}`); + } + } + + if (mode === 'month' && dest.month) { + const monthKey = `${dest.year}/${dest.month}`; + if (!monthIdFor.has(monthKey)) { + const yearRef = yearIdFor.get(dest.year)!; + // Only look up existing month folders under real (non-creation-ref) year ids. + const existingMonth = yearRef.startsWith('#') + ? undefined + : findExisting(dest.month, yearRef); + if (existingMonth) { + monthIdFor.set(monthKey, existingMonth.originalId || existingMonth.id); + } else { + const cid = `month-${dest.year}-${dest.month}`; + createEntries[cid] = { name: dest.month, parentId: yearRef }; + monthIdFor.set(monthKey, `#${cid}`); + } + } + } + } + + const updates: Record }> = {}; + for (const [emailId, dest] of destFor.entries()) { + const destId = mode === 'month' && dest.month + ? monthIdFor.get(`${dest.year}/${dest.month}`)! + : yearIdFor.get(dest.year)!; + updates[emailId] = { mailboxIds: { [destId]: true } }; + } + + const methodCalls: JMAPMethodCall[] = []; + const hasCreates = Object.keys(createEntries).length > 0; + if (hasCreates) { + methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']); + } + methodCalls.push(['Email/set', { accountId: targetAccountId, update: updates }, String(methodCalls.length)]); + + const response = await this.request(methodCalls); + + if (hasCreates) { + const mailboxResult = response.methodResponses?.[0]?.[1]; + const notCreated = mailboxResult?.notCreated as Record | undefined; + const failures = notCreated ? Object.entries(notCreated) : []; + if (failures.length > 0) { + const [cid, err] = failures[0]; + const parts = [err.type || 'unknown']; + if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`); + if (err.description) parts.push(err.description); + throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' — ')}`); + } + } + + const emailIdx = hasCreates ? 1 : 0; + const emailResult = response.methodResponses?.[emailIdx]?.[1]; + const notUpdated = emailResult?.notUpdated as Record | undefined; + const emailFailures = notUpdated ? Object.entries(notUpdated) : []; + if (emailFailures.length > 0) { + const [id, err] = emailFailures[0]; + throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} — ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`); + } + } + async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; const response = await this.request([ diff --git a/stores/email-store.ts b/stores/email-store.ts index 157b367a..4de96988 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -89,6 +89,7 @@ interface EmailStore { batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise; batchDelete: (client: IJMAPClient, permanent?: boolean) => Promise; batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise; + batchArchive: (client: IJMAPClient) => Promise; // Spam operations spamUndoCache: Map; @@ -1190,6 +1191,43 @@ export const useEmailStore = create((set, get) => ({ } }, + batchArchive: async (client) => { + const { selectedEmailIds, emails, mailboxes, fetchMailboxes, fetchEmails, selectedMailbox } = get(); + if (selectedEmailIds.size === 0) return; + + const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive'); + if (!archiveMailbox) return; + + const mode = useSettingsStore.getState().archiveMode; + const archiveId = archiveMailbox.originalId || archiveMailbox.id; + + const selected = emails.filter(e => selectedEmailIds.has(e.id)); + if (selected.length === 0) return; + + set({ isLoading: true, error: null }); + try { + await client.batchArchiveEmails( + selected.map(e => ({ id: e.id, receivedAt: e.receivedAt })), + archiveId, + mode, + mailboxes, + archiveMailbox.accountId, + ); + + const remaining = emails.filter(e => !selectedEmailIds.has(e.id)); + set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false }); + + await fetchMailboxes(client); + await fetchEmails(client, selectedMailbox); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to archive emails', + isLoading: false, + }); + throw error; + } + }, + // Spam operations markAsSpam: async (client, emailId) => { const { selectedMailbox, mailboxes, emails } = get();