feat: implement batch archiving of emails
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||
|
||||
@@ -85,6 +85,13 @@ export interface IJMAPClient {
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
|
||||
batchArchiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
existingMailboxes: Mailbox[],
|
||||
accountId?: string,
|
||||
): Promise<void>;
|
||||
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||
emptyMailbox(mailboxId: string): Promise<number>;
|
||||
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, Dest>();
|
||||
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 ("#<cid>").
|
||||
const yearIdFor = new Map<string, string>();
|
||||
const monthIdFor = new Map<string, string>();
|
||||
const createEntries: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
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<string, { mailboxIds: Record<string, true> }> = {};
|
||||
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<string, { type?: string; properties?: string[]; description?: string }> | 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<string, { type?: string; description?: string }> | 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<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const response = await this.request([
|
||||
|
||||
@@ -89,6 +89,7 @@ interface EmailStore {
|
||||
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
|
||||
batchDelete: (client: IJMAPClient, permanent?: boolean) => Promise<void>;
|
||||
batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
||||
batchArchive: (client: IJMAPClient) => Promise<void>;
|
||||
|
||||
// Spam operations
|
||||
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
|
||||
@@ -1190,6 +1191,43 @@ export const useEmailStore = create<EmailStore>((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();
|
||||
|
||||
Reference in New Issue
Block a user