fix: stop silently destroying emails when trash mailbox isnt found #195

This commit is contained in:
Linus Rath
2026-05-01 20:01:13 +02:00
parent e50691d6c4
commit eecf16daa2
3 changed files with 96 additions and 18 deletions
+12 -1
View File
@@ -932,13 +932,24 @@ export default function Home() {
}
} else {
// Not in trash: always move to trash
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
const trashMailbox =
mailboxes.find(m => m.role === 'trash' && !m.isShared) ??
mailboxes.find(m => {
if (m.isShared) return false;
const lower = m.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted');
});
if (trashMailbox) {
try {
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
} catch (error) {
console.error("Failed to move email to trash:", error);
const { toast } = await import('sonner');
toast.error(error instanceof Error ? error.message : 'Failed to move email to trash');
}
} else {
const { toast } = await import('sonner');
toast.error('Trash mailbox not found - cannot move email to trash');
}
}
};
+8
View File
@@ -178,6 +178,14 @@ export function EmailList({
setIsProcessing(true);
try {
await batchDelete(client, isInTrash);
const storeError = useEmailStore.getState().error;
if (storeError) {
const { toast } = await import('sonner');
toast.error(storeError);
}
} catch (err) {
const { toast } = await import('sonner');
toast.error(err instanceof Error ? err.message : 'Failed to delete emails');
} finally {
setTimeout(() => setIsProcessing(false), 500);
}
+76 -17
View File
@@ -161,6 +161,28 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
}
// Find the trash mailbox for a given account scope. Prefers JMAP role, but
// falls back to name matching ("trash" / "deleted") so users with custom or
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
function findTrashMailbox(
mailboxes: Mailbox[],
scope: { accountId?: string; isShared?: boolean }
): Mailbox | undefined {
const matchesScope = (mb: Mailbox): boolean => {
if (scope.accountId) return mb.accountId === scope.accountId;
return !mb.isShared;
};
const byRole = mailboxes.find(mb => mb.role === 'trash' && matchesScope(mb));
if (byRole) return byRole;
return mailboxes.find(mb => {
if (!matchesScope(mb)) return false;
const lower = mb.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted');
});
}
export const useEmailStore = create<EmailStore>((set, get) => ({
emails: [],
mailboxes: [],
@@ -567,15 +589,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox
if (deleteAction === 'trash' && !forceDelete) {
// Find trash mailbox for the correct account
const trashMailbox = mailboxes.find(mb => {
if (accountId) {
// For shared folders, match by accountId
return mb.role === 'trash' && mb.accountId === accountId;
}
// For primary account, find trash that's not from a shared folder
return mb.role === 'trash' && !mb.isShared;
});
const trashMailbox = findTrashMailbox(mailboxes, { accountId });
if (trashMailbox) {
// Use originalId for shared mailboxes if available
@@ -620,7 +634,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
return;
}
// If no trash mailbox found, fall through to permanent delete
// No trash folder found in this account. Surface the failure rather
// than silently destroying the email - the user asked to move it to
// trash, not to permanently delete it.
throw new Error('Trash mailbox not found - cannot move email to trash');
}
// Permanent delete
@@ -1175,23 +1192,65 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await Promise.allSettled(promises);
} else {
// Move to trash per account.
const failedAccounts: string[] = [];
const movedEmailIds = new Set<string>();
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = getClient(acctId);
if (!acctClient) return;
const trashMailbox = mailboxes.find(mb => {
if (mb.role !== 'trash') return false;
if (acctId === '__default__') return !mb.isShared;
return mb.accountId === acctId;
if (!acctClient) {
failedAccounts.push(acctId);
return;
}
const trashMailbox = findTrashMailbox(mailboxes, {
accountId: acctId === '__default__' ? undefined : acctId,
});
if (!trashMailbox) {
// No trash available for this account - fall back to destroy so the action isn't silently dropped.
await acctClient.batchDeleteEmails(ids);
// No trash for this account: skip rather than silently destroying.
// The user asked to move to trash, not permanently delete.
failedAccounts.push(acctId);
return;
}
const trashId = trashMailbox.originalId || trashMailbox.id;
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId);
ids.forEach(id => movedEmailIds.add(id));
});
await Promise.allSettled(promises);
if (failedAccounts.length > 0 && movedEmailIds.size === 0) {
// Nothing moved - bail out so the UI doesn't drop the emails from view.
throw new Error('Trash mailbox not found - cannot move emails to trash');
}
// Only remove successfully moved emails from local state.
if (movedEmailIds.size < emailIdsArray.length) {
const deletedEmails = emails.filter(e => movedEmailIds.has(e.id));
const remainingEmails = emails.filter(e => !movedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => {
let deltaTotalEmails = 0;
let deltaUnreadEmails = 0;
deletedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
deltaTotalEmails--;
if (!email.keywords?.$seen) deltaUnreadEmails--;
}
});
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails),
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails),
totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails),
};
});
set({
emails: remainingEmails,
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
selectedEmail: null,
isLoading: false,
error: 'Some emails could not be moved: trash folder missing for one or more accounts',
});
return;
}
}
// Remove deleted emails from local state