feat: implement batch archiving of emails
This commit is contained in:
@@ -69,6 +69,7 @@ export function EmailList({
|
|||||||
batchMarkAsRead,
|
batchMarkAsRead,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
batchMoveToMailbox,
|
batchMoveToMailbox,
|
||||||
|
batchArchive,
|
||||||
batchMarkAsSpam,
|
batchMarkAsSpam,
|
||||||
batchUndoSpam,
|
batchUndoSpam,
|
||||||
loadMoreEmails,
|
loadMoreEmails,
|
||||||
@@ -497,12 +498,12 @@ export function EmailList({
|
|||||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||||
onBatchDelete={() => client && batchDelete(client)}
|
onBatchDelete={() => client && batchDelete(client)}
|
||||||
onBatchArchive={async () => {
|
onBatchArchive={async () => {
|
||||||
if (!onArchive) return;
|
if (!client) return;
|
||||||
const selected = emails.filter((e) => selectedEmailIds.has(e.id));
|
try {
|
||||||
for (const email of selected) {
|
await batchArchive(client);
|
||||||
await onArchive(email);
|
} catch (error) {
|
||||||
|
console.error('Failed to batch archive:', error);
|
||||||
}
|
}
|
||||||
clearSelection();
|
|
||||||
}}
|
}}
|
||||||
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
||||||
onBatchMarkAsSpam={async () => {
|
onBatchMarkAsSpam={async () => {
|
||||||
|
|||||||
@@ -269,6 +269,35 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
this.recalcMailboxCounts();
|
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> {
|
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
|
||||||
const email = this.data.emails.find(e => e.id === emailId);
|
const email = this.data.emails.find(e => e.id === emailId);
|
||||||
if (email) email.mailboxIds = { [toMailboxId]: true };
|
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ export interface IJMAPClient {
|
|||||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
||||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||||
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: 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>;
|
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||||
emptyMailbox(mailboxId: string): Promise<number>;
|
emptyMailbox(mailboxId: string): Promise<number>;
|
||||||
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
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> {
|
async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ interface EmailStore {
|
|||||||
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
|
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
|
||||||
batchDelete: (client: IJMAPClient, permanent?: boolean) => Promise<void>;
|
batchDelete: (client: IJMAPClient, permanent?: boolean) => Promise<void>;
|
||||||
batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
||||||
|
batchArchive: (client: IJMAPClient) => Promise<void>;
|
||||||
|
|
||||||
// Spam operations
|
// Spam operations
|
||||||
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
|
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
|
// Spam operations
|
||||||
markAsSpam: async (client, emailId) => {
|
markAsSpam: async (client, emailId) => {
|
||||||
const { selectedMailbox, mailboxes, emails } = get();
|
const { selectedMailbox, mailboxes, emails } = get();
|
||||||
|
|||||||
Reference in New Issue
Block a user