feat: add "Move to Trash and mark as read" delete action #323

This commit is contained in:
Linus Rath
2026-05-22 19:08:02 +02:00
parent 5aa6d7a2f0
commit acc90eb455
8 changed files with 56 additions and 34 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] }, { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' }, { key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] }, { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] }, { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' }, { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
+2 -1
View File
@@ -120,9 +120,10 @@ export function ReadingSettings() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Select <Select
value={deleteAction} value={deleteAction}
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')} onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'trash-and-read' | 'permanent')}
options={[ options={[
{ value: 'trash', label: t('delete_action.trash') }, { value: 'trash', label: t('delete_action.trash') },
{ value: 'trash-and-read', label: t('delete_action.trash_and_read') },
{ value: 'permanent', label: t('delete_action.permanent') }, { value: 'permanent', label: t('delete_action.permanent') },
]} ]}
/> />
+12 -5
View File
@@ -264,10 +264,11 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts(); this.recalcMailboxCounts();
} }
async moveToTrash(emailId: string, trashMailboxId: string): Promise<void> { async moveToTrash(emailId: string, trashMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId); const email = this.data.emails.find(e => e.id === emailId);
if (!email) return; if (!email) return;
email.mailboxIds = { [trashMailboxId]: true }; email.mailboxIds = { [trashMailboxId]: true };
if (markAsRead) email.keywords.$seen = true;
this.recalcMailboxCounts(); this.recalcMailboxCounts();
} }
@@ -277,10 +278,13 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts(); this.recalcMailboxCounts();
} }
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> { async batchMoveEmails(emailIds: string[], toMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
for (const id of emailIds) { for (const id of emailIds) {
const email = this.data.emails.find(e => e.id === id); const email = this.data.emails.find(e => e.id === id);
if (email) email.mailboxIds = { [toMailboxId]: true }; if (email) {
email.mailboxIds = { [toMailboxId]: true };
if (markAsRead) email.keywords.$seen = true;
}
} }
this.recalcMailboxCounts(); this.recalcMailboxCounts();
} }
@@ -355,10 +359,13 @@ export class DemoJMAPClient implements IJMAPClient {
return count; return count;
} }
async markAsSpam(emailId: string): Promise<void> { async markAsSpam(emailId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId); const email = this.data.emails.find(e => e.id === emailId);
const junkMb = this.data.mailboxes.find(m => m.role === 'junk'); const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
if (email && junkMb) email.mailboxIds = { [junkMb.id]: true }; if (email && junkMb) {
email.mailboxIds = { [junkMb.id]: true };
if (markAsRead) email.keywords.$seen = true;
}
this.recalcMailboxCounts(); this.recalcMailboxCounts();
} }
+3 -3
View File
@@ -96,9 +96,9 @@ export interface IJMAPClient {
setKeyword(emailId: string, keyword: string): Promise<void>; setKeyword(emailId: string, keyword: string): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>; migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string): Promise<void>; deleteEmail(emailId: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): 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, markAsRead?: boolean): Promise<void>;
batchArchiveEmails( batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>, emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string, archiveMailboxId: string,
@@ -110,7 +110,7 @@ export interface IJMAPClient {
emptyMailbox(mailboxId: string): Promise<number>; emptyMailbox(mailboxId: string): Promise<number>;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>; markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>; markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
markAsSpam(emailId: string, accountId?: string): Promise<void>; markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>; undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
// ── Threads ─────────────────────────────────────────────────── // ── Threads ───────────────────────────────────────────────────
+16 -14
View File
@@ -1226,16 +1226,14 @@ export class JMAPClient implements IJMAPClient {
]); ]);
} }
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void> { async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
const targetAccountId = accountId || this.accountId; const targetAccountId = accountId || this.accountId;
const patch: Record<string, unknown> = { mailboxIds: { [trashMailboxId]: true } };
if (markAsRead) patch["keywords/$seen"] = true;
await this.request([ await this.request([
["Email/set", { ["Email/set", {
accountId: targetAccountId, accountId: targetAccountId,
update: { update: { [emailId]: patch },
[emailId]: {
mailboxIds: { [trashMailboxId]: true },
},
},
}, "0"], }, "0"],
]); ]);
} }
@@ -1251,10 +1249,15 @@ export class JMAPClient implements IJMAPClient {
]); ]);
} }
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void> { async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
if (emailIds.length === 0) return; if (emailIds.length === 0) return;
const updates = Object.fromEntries(emailIds.map(id => [id, { mailboxIds: { [toMailboxId]: true } }])); const buildPatch = () => {
const patch: Record<string, unknown> = { mailboxIds: { [toMailboxId]: true } };
if (markAsRead) patch["keywords/$seen"] = true;
return patch;
};
const updates = Object.fromEntries(emailIds.map(id => [id, buildPatch()]));
await this.request([ await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"], ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]); ]);
@@ -1507,7 +1510,7 @@ export class JMAPClient implements IJMAPClient {
return totalMarked; return totalMarked;
} }
async markAsSpam(emailId: string, accountId?: string): Promise<void> { async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
const targetAccountId = accountId || this.accountId; const targetAccountId = accountId || this.accountId;
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes();
@@ -1526,14 +1529,13 @@ export class JMAPClient implements IJMAPClient {
? junkMailbox.originalId ? junkMailbox.originalId
: junkMailbox.id; : junkMailbox.id;
const patch: Record<string, unknown> = { mailboxIds: { [mailboxId]: true } };
if (markAsRead) patch["keywords/$seen"] = true;
await this.request([ await this.request([
["Email/set", { ["Email/set", {
accountId: targetAccountId, accountId: targetAccountId,
update: { update: { [emailId]: patch },
[emailId]: {
mailboxIds: { [mailboxId]: true },
},
},
}, "0"], }, "0"],
]); ]);
} }
+1
View File
@@ -974,6 +974,7 @@
"label": "Delete Action", "label": "Delete Action",
"description": "What happens when you delete an email", "description": "What happens when you delete an email",
"trash": "Move to Trash", "trash": "Move to Trash",
"trash_and_read": "Move to Trash and mark as read",
"permanent": "Delete Permanently", "permanent": "Delete Permanently",
"warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible." "warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible."
}, },
+20 -9
View File
@@ -773,14 +773,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
forceDelete = true; forceDelete = true;
} }
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox // If deleteAction is 'trash' or 'trash-and-read' and not forced permanent delete, try to move to trash mailbox
if (deleteAction === 'trash' && !forceDelete) { if ((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete) {
const trashMailbox = findTrashMailbox(mailboxes, { accountId }); const trashMailbox = findTrashMailbox(mailboxes, { accountId });
const alsoMarkRead = deleteAction === 'trash-and-read' && isUnread;
if (trashMailbox) { if (trashMailbox) {
// Use originalId for shared mailboxes if available // Use originalId for shared mailboxes if available
const trashId = trashMailbox.originalId || trashMailbox.id; const trashId = trashMailbox.originalId || trashMailbox.id;
await effectiveClient.moveToTrash(emailId, trashId, accountId); await effectiveClient.moveToTrash(emailId, trashId, accountId, alsoMarkRead);
// After marking read in the same request, the email arrives in trash as read.
const arrivesUnread = isUnread && !alsoMarkRead;
// Remove from local state (email moved to trash, not in current view) // Remove from local state (email moved to trash, not in current view)
set((state) => { set((state) => {
@@ -803,9 +807,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return { return {
...mailbox, ...mailbox,
totalEmails: mailbox.totalEmails + 1, totalEmails: mailbox.totalEmails + 1,
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails, unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
totalThreads: mailbox.totalThreads + 1, totalThreads: mailbox.totalThreads + 1,
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
}; };
} }
return mailbox; return mailbox;
@@ -1514,6 +1518,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
const isInJunk = currentMailbox?.role === 'junk'; const isInJunk = currentMailbox?.role === 'junk';
const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk); const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk);
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
// Group emails by accountId (handles unified view and search results spanning accounts). // Group emails by accountId (handles unified view and search results spanning accounts).
const emailsByAccount = new Map<string, string[]>(); const emailsByAccount = new Map<string, string[]>();
@@ -1554,7 +1559,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return; return;
} }
const trashId = trashMailbox.originalId || trashMailbox.id; const trashId = trashMailbox.originalId || trashMailbox.id;
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId); await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId, alsoMarkRead);
ids.forEach(id => movedEmailIds.add(id)); ids.forEach(id => movedEmailIds.add(id));
}); });
await Promise.allSettled(promises); await Promise.allSettled(promises);
@@ -1745,7 +1750,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}); });
try { try {
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId); const isUnread = !email.keywords?.$seen;
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread;
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId, alsoMarkRead);
set(state => ({ set(state => ({
emails: state.emails.filter(e => e.id !== emailId), emails: state.emails.filter(e => e.id !== emailId),
@@ -1800,16 +1807,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}, },
batchMarkAsSpam: async (client, emailIds) => { batchMarkAsSpam: async (client, emailIds) => {
const { selectedMailbox } = get(); const { selectedMailbox, emails } = get();
const mailboxes = resolveActionMailboxes(); const mailboxes = resolveActionMailboxes();
const effectiveClient = resolveActionClient(client); const effectiveClient = resolveActionClient(client);
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return; if (!currentMailbox) return;
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
try { try {
for (const emailId of emailIds) { for (const emailId of emailIds) {
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId); const email = emails.find(e => e.id === emailId);
const markRead = alsoMarkRead && !!email && !email.keywords?.$seen;
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId, markRead);
} }
set(state => ({ set(state => ({
+1 -1
View File
@@ -28,7 +28,7 @@ export type FontSize = 'small' | 'medium' | 'large';
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
/** @deprecated Use Density instead */ /** @deprecated Use Density instead */
export type ListDensity = Density; export type ListDensity = Density;
export type DeleteAction = 'trash' | 'permanent'; export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
export type ReplyMode = 'reply' | 'replyAll'; export type ReplyMode = 'reply' | 'replyAll';
export type SignaturePosition = 'above_quote' | 'below_quote'; export type SignaturePosition = 'above_quote' | 'below_quote';
export type DateFormat = 'regional' | 'iso' | 'custom'; export type DateFormat = 'regional' | 'iso' | 'custom';