diff --git a/CLAUDE.md b/CLAUDE.md index 4faea22..7511614 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,6 @@ src/ - **User preferences are schemaless rows**, not columns: the `user_preferences` table stores one row per `(user_id, key)` with the value in a JSON `data` column. Both the remote-content-policy and auto-mark-as-seen preferences live here (keyed strings), accessed via `UserPreferencesHandler` (`utils/preferences.ts`). Adding a preference does **not** require a migration. `GET /account/preferences` returns every preference in one response via `UserPreferencesHandler.getAll`, derived from `UserPreferences.schemas`, so a new key shows up there automatically (it still needs its own GET/PUT sub-routes). - **Every mail account has at least one sender identity**: `POST /mail-accounts` creates the account and its default identity in one transaction, taking the address from the optional `identity` body field or, failing that, from the SMTP username when that is an email address — neither means a 400, because an account with no address can't send. `DELETE .../identities/:id` refuses the account's last identity with a 409, and deleting the default hands that flag to the oldest remaining identity. Accounts predating the rule are backfilled by the `0014_backfill_default_mail_identities` migration. It cannot read the account's own address — that lives in the encrypted connection blob — so it builds the identity from the owner's `users.email` and leaves it **non-default**, which keeps a possibly-wrong address out of the composer's preselected sender (the account's decrypted SMTP address is listed first). Users correct it under the account's Identities settings. Each identity also carries an optional `signature` (nullable HTML, max 16 KB) that the web composer appends to mails sent from it; omitting the field on a `PUT` leaves it untouched, `null` clears it. - **BIMI is resolved live, never persisted**: the `bimi/:domain` route uses `BimiService` to read the sender domain's BIMI DNS record and return the brand `logoUrl`. Only DNS metadata is read — the logo SVG is never fetched or stored server-side; the client loads it directly. -- **Bulk mail actions** (`mailboxes/.../mail-bulk-actions`) apply move/copy/delete/flag operations to many UIDs in a single IMAP round-trip, separate from the per-mail routes under `mails/`. +- **Bulk mail actions** (`mailboxes/.../mail-bulk-actions`) apply move/copy/delete/flag operations to many UIDs in a single IMAP round-trip, separate from the per-mail routes under `mails/`. `delete-all` does the same as `delete` for every mail in the folder, using a `min:max` UID range. The move/copy/delete methods in `IMAPAccount` throw when imapflow returns `false`/`undefined` (that's how it reports failures); the flag methods don't, because a no-op STORE also returns `false`. - **Outbound system mail** (e.g. password-reset emails) uses the `DLA_SMTP_*` config vars — distinct from the per-account IMAP/SMTP credentials stored encrypted in `mail_accounts`. - The `data/` directory contains runtime data (SQLite DB files, etc.). diff --git a/src/api/versions/v1/routes/mail-accounts/mailboxes/mail-bulk-actions/index.ts b/src/api/versions/v1/routes/mail-accounts/mailboxes/mail-bulk-actions/index.ts index 581bece..15efe77 100644 --- a/src/api/versions/v1/routes/mail-accounts/mailboxes/mail-bulk-actions/index.ts +++ b/src/api/versions/v1/routes/mail-accounts/mailboxes/mail-bulk-actions/index.ts @@ -7,6 +7,7 @@ import { APIResponse } from "../../../../../../utils/api-res"; import { Logger } from "../../../../../../../utils/logger"; import { MailClientsCache } from "../../../../../../../utils/mails/mail-clients-cache"; import { SpecialUseHandler } from "../../../../../../utils/services/specialUseService"; +import type { MailAccountsModel } from "../../model"; export const router = new Hono(); @@ -129,6 +130,48 @@ router.post('/delete', } ); +router.post('/delete-all', + + APIRouteSpec.authenticated({ + summary: "Bulk Delete All Mails", + description: "Empty the mailbox: delete every mail in it, not just the loaded page, by moving them to Trash or permanently deleting them. Mails in the Trash folder itself are always deleted permanently.", + tags: [DOCS_TAGS.MAIL_ACCOUNTS.MAILBOXES_MAIL_BULK_ACTIONS], + + responses: APIResponseSpec.describeWithWrongInputs( + APIResponseSpec.success("All mails deleted successfully", MailBulkActionsModel.BulkDeleteAll.Response), + APIResponseSpec.notFound("Mailbox with specified path not found") + ) + }), + + validator('json', MailBulkActionsModel.BulkDeleteAll.Body), + + async (c) => { + // @ts-ignore + const mailAccount = c.get("mailAccount") as MailAccountsModel.BASE; + // @ts-ignore + const mailbox = c.get("mailboxData") as MailboxesModel.BASE; + + const body = c.req.valid('json'); + + const imap = MailClientsCache.createOrGetClientData(mailAccount).imap; + + try { + await imap.connect(); + + // Like /delete, but for every mail. + const trashPath = body.permanent + ? null + : await SpecialUseHandler.resolveTrashPath(mailAccount.id, imap); + const deletedCount = await imap.deleteAll(mailbox.path, trashPath); + + return APIResponse.success(c, "All mails deleted successfully", { success: true, deletedCount } satisfies MailBulkActionsModel.BulkDeleteAll.Response); + } catch (e) { + Logger.error("Failed to delete all mails", e); + return APIResponse.serverError(c, "Failed to delete all mails"); + } + } +); + router.post('/flags', APIRouteSpec.authenticated({ diff --git a/src/api/versions/v1/routes/mail-accounts/mailboxes/mail-bulk-actions/model.ts b/src/api/versions/v1/routes/mail-accounts/mailboxes/mail-bulk-actions/model.ts index fc29ca7..d5eabb5 100644 --- a/src/api/versions/v1/routes/mail-accounts/mailboxes/mail-bulk-actions/model.ts +++ b/src/api/versions/v1/routes/mail-accounts/mailboxes/mail-bulk-actions/model.ts @@ -48,6 +48,22 @@ export namespace MailBulkActionsModel.BulkDelete { export type Response = z.infer; } +export namespace MailBulkActionsModel.BulkDeleteAll { + + export const Body = z.object({ + permanent: z.boolean().default(false).describe("If true, permanently delete every mail. Otherwise, move them to Trash. Mails in the Trash folder itself are always deleted permanently.") + }); + + export type Body = z.infer; + + export const Response = z.object({ + success: z.boolean(), + deletedCount: z.number().describe("Number of mails in the mailbox when it was emptied") + }); + + export type Response = z.infer; +} + export namespace MailBulkActionsModel.BulkSetFlags { /** Only the provided flags are changed; `true` sets the flag, `false` clears it. */ diff --git a/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/index.ts b/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/index.ts index 73a735e..d4c4b35 100644 --- a/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/index.ts +++ b/src/api/versions/v1/routes/mail-accounts/mailboxes/mails/index.ts @@ -516,10 +516,16 @@ router.put('/:mailUID', // The new mail replaces the old version, so remove it. Servers // without UIDPLUS can't expunge a single UID, so they fall back // to the Trash folder instead of expunging the whole mailbox. - const trashPath = imap.supportsUidExpunge() - ? null - : await SpecialUseHandler.resolveTrashPath(mailAccount.id, imap); - await imap.deleteReplacedMails(mailbox.path, [mailData.uid], trashPath); + // The new version is already saved, so don't fail the request here; + // a retry would only append another copy. + try { + const trashPath = imap.supportsUidExpunge() + ? null + : await SpecialUseHandler.resolveTrashPath(mailAccount.id, imap); + await imap.deleteReplacedMails(mailbox.path, [mailData.uid], trashPath); + } catch (e) { + Logger.error(`Replaced draft ${newUid}, but failed to remove the old version ${mailData.uid}`, e); + } } else if (body.flags) { // Flag-only updates stay on the original IMAP message. Rebuilding the // MIME message here would unnecessarily replace its UID and risk loss. diff --git a/src/utils/mails/backends/imap.ts b/src/utils/mails/backends/imap.ts index 7d00658..4ca482c 100644 --- a/src/utils/mails/backends/imap.ts +++ b/src/utils/mails/backends/imap.ts @@ -178,11 +178,10 @@ export class IMAPAccount { { body: searchString } ] }, { uid: true }); - uids = searchResults as number[]; + if (!Array.isArray(searchResults)) throw new Error(`Failed to search the mails in ${mailbox}`); + uids = searchResults; } else { - // Get all message sequence numbers - const allMessages = await this.client.search({ all: true }, { uid: true }); - uids = allMessages as number[]; + uids = await this.searchAllUids(mailbox); } if (uids.length === 0) return []; @@ -292,7 +291,7 @@ export class IMAPAccount { } } - async addFlags(mailbox: string, uids: number[], flags: string[]) { + async addFlags(mailbox: string, uids: IMAPAccount.UidRange, flags: string[]) { let lock = await this.client.getMailboxLock(mailbox); try { await this.client.messageFlagsAdd(uids, flags, { uid: true }); @@ -301,7 +300,7 @@ export class IMAPAccount { } } - async removeFlags(mailbox: string, uids: number[], flags: string[]) { + async removeFlags(mailbox: string, uids: IMAPAccount.UidRange, flags: string[]) { let lock = await this.client.getMailboxLock(mailbox); try { await this.client.messageFlagsRemove(uids, flags, { uid: true }); @@ -310,19 +309,19 @@ export class IMAPAccount { } } - async moveToMailbox(mailbox: string, uids: number[], targetMailbox: string) { + async moveToMailbox(mailbox: string, uids: IMAPAccount.UidRange, targetMailbox: string) { let lock = await this.client.getMailboxLock(mailbox); try { - await this.client.messageMove(uids, targetMailbox, { uid: true }); + IMAPAccount.ensure(await this.client.messageMove(uids, targetMailbox, { uid: true }), `Failed to move mails from ${mailbox} to ${targetMailbox}`); } finally { lock.release(); } } - async copyToMailbox(mailbox: string, uids: number[], targetMailbox: string) { + async copyToMailbox(mailbox: string, uids: IMAPAccount.UidRange, targetMailbox: string) { let lock = await this.client.getMailboxLock(mailbox); try { - await this.client.messageCopy(uids, targetMailbox, { uid: true }); + IMAPAccount.ensure(await this.client.messageCopy(uids, targetMailbox, { uid: true }), `Failed to copy mails from ${mailbox} to ${targetMailbox}`); } finally { lock.release(); } @@ -334,7 +333,7 @@ export class IMAPAccount { * ({@link SpecialUseHandler.resolveTrashPath}) rather than re-guessed here on * every delete. */ - async moveToTrash(mailbox: string, uids: number[], trashPath: string) { + async moveToTrash(mailbox: string, uids: IMAPAccount.UidRange, trashPath: string) { // Deleting from within the Trash folder can't move anywhere further, and a // move onto itself would be a silent no-op that looks successful — treat it // as a permanent delete instead. @@ -344,7 +343,7 @@ export class IMAPAccount { let lock = await this.client.getMailboxLock(mailbox); try { - await this.client.messageMove(uids, trashPath, { uid: true }); + IMAPAccount.ensure(await this.client.messageMove(uids, trashPath, { uid: true }), `Failed to move mails from ${mailbox} to ${trashPath}`); } finally { lock.release(); } @@ -354,15 +353,42 @@ export class IMAPAccount { * Permanently removes messages: imapflow's `messageDelete` flags the given * UIDs `\Deleted` and expunges them, unlike `addFlags` alone. */ - async permanentlyDelete(mailbox: string, uids: number[]) { + async permanentlyDelete(mailbox: string, uids: IMAPAccount.UidRange) { let lock = await this.client.getMailboxLock(mailbox); try { - await this.client.messageDelete(uids, { uid: true }); + IMAPAccount.ensure(await this.client.messageDelete(uids, { uid: true }), `Failed to delete mails from ${mailbox}`); } finally { lock.release(); } } + async deleteAll(mailbox: string, trashPath: string | null): Promise { + let lock = await this.client.getMailboxLock(mailbox); + try { + const uids = await this.searchAllUids(mailbox); + if (uids.length === 0) return 0; + + // A min:max range keeps the command short. + const range = IMAPAccount.toUidRange(uids); + if (trashPath === null || trashPath.toLowerCase() === mailbox.toLowerCase()) { + IMAPAccount.ensure(await this.client.messageDelete(range, { uid: true }), `Failed to delete mails from ${mailbox}`); + } else { + IMAPAccount.ensure(await this.client.messageMove(range, trashPath, { uid: true }), `Failed to move mails from ${mailbox} to ${trashPath}`); + } + return uids.length; + } finally { + lock.release(); + } + } + + /** Searched instead of using `exists`, which can be stale on a reused connection. Callers hold the lock. */ + private async searchAllUids(mailbox: string): Promise { + const uids = await this.client.search({ all: true }, { uid: true }); + // imapflow returns false/undefined instead of throwing + if (!Array.isArray(uids)) throw new Error(`Failed to list the mails in ${mailbox}`); + return uids; + } + /** Whether the server can expunge individual UIDs (RFC 4315 UIDPLUS). */ supportsUidExpunge(): boolean { return this.client.capabilities.has('UIDPLUS'); @@ -653,6 +679,25 @@ export class IMAPAccount { export namespace IMAPAccount { + /** A UID list or a sequence set like "100:250". */ + export type UidRange = number[] | string; + + /** min:max of the UIDs. New mails get higher UIDs, so they're not included. */ + export function toUidRange(uids: number[]): string { + let min = uids[0]!, max = uids[0]!; + for (const uid of uids) { + if (uid < min) min = uid; + if (uid > max) max = uid; + } + return `${min}:${max}`; + } + + /** imapflow returns false/undefined on failure instead of throwing. */ + export function ensure(result: T | false | undefined, message: string): T { + if (result === false || result === undefined) throw new Error(message); + return result; + } + export interface ConfigOptions { host: string; port: number; diff --git a/tests/api.routes.test.ts b/tests/api.routes.test.ts index d8a633c..7e0dc3d 100644 --- a/tests/api.routes.test.ts +++ b/tests/api.routes.test.ts @@ -26,6 +26,7 @@ import { SMTPAccount } from "../src/utils/mails/backends/smtp"; import { MailParser } from "../src/utils/mails/parser"; import { ConfigHandler } from "../src/utils/config"; import { Logger } from "../src/utils/logger"; +import { ImapFlow } from "imapflow"; type SeededUser = Omit & { password: string }; type SeededSession = Awaited>; @@ -3597,6 +3598,153 @@ describe("Mail Mailbox Mails Routes", async () => { expect(trashMails.length).toBe(0); }); + test("POST /v1/mail-accounts/:mailAccountID/mailboxes/:mailboxPath/mail-bulk-actions/delete-all moves every mail to trash", async () => { + + await testIMAPClient.createMailbox("TestDeleteAll"); + + for (const subject of ["Delete All 1", "Delete All 2", "Delete All 3"]) { + await makeAPIRequest(`/v1/mail-accounts/${mailAccountID}/mailboxes/TestDeleteAll/mails`, { + method: "POST", + authToken: session_token, + body: { + from: { name: "Bulk Test", address: "bulk@test.com" }, + to: [{ name: "Receiver", address: "receiver@test.com" }], + cc: [], + bcc: [], + subject, + body: { text: "delete all test" } + }, + expectedBodySchema: MailsModel.Create.Response + }); + } + + const data = await makeAPIRequest(`/v1/mail-accounts/${mailAccountID}/mailboxes/TestDeleteAll/mail-bulk-actions/delete-all`, { + method: "POST", + authToken: session_token, + body: {}, + expectedBodySchema: MailBulkActionsModel.BulkDeleteAll.Response + }); + + expect(data.success).toBe(true); + expect(data.deletedCount).toBe(3); + + expect((await testIMAPClient.getMails("TestDeleteAll")).length).toBe(0); + const trashMails = await testIMAPClient.getMails("Trash", { searchString: "Delete All" }); + expect(trashMails.length).toBeGreaterThanOrEqual(3); + + // Already empty: no error, just 0 + const again = await makeAPIRequest(`/v1/mail-accounts/${mailAccountID}/mailboxes/TestDeleteAll/mail-bulk-actions/delete-all`, { + method: "POST", + authToken: session_token, + body: {}, + expectedBodySchema: MailBulkActionsModel.BulkDeleteAll.Response + }); + expect(again.deletedCount).toBe(0); + + await testIMAPClient.deleteMailbox("TestDeleteAll"); + }); + + test("POST /v1/mail-accounts/:mailAccountID/mailboxes/:mailboxPath/mail-bulk-actions/delete-all on Trash permanently empties it", async () => { + + await makeAPIRequest(`/v1/mail-accounts/${mailAccountID}/mailboxes/Trash/mails`, { + method: "POST", + authToken: session_token, + body: { + from: { name: "Bulk Test", address: "bulk@test.com" }, + to: [{ name: "Receiver", address: "receiver@test.com" }], + cc: [], + bcc: [], + subject: "Empty Trash", + body: { text: "empty trash test" } + }, + expectedBodySchema: MailsModel.Create.Response + }); + + const data = await makeAPIRequest(`/v1/mail-accounts/${mailAccountID}/mailboxes/Trash/mail-bulk-actions/delete-all`, { + method: "POST", + authToken: session_token, + body: {}, + expectedBodySchema: MailBulkActionsModel.BulkDeleteAll.Response + }); + + expect(data.success).toBe(true); + expect(data.deletedCount).toBeGreaterThanOrEqual(1); + // Other tests share Trash, so only check our own mail + expect((await testIMAPClient.getMails("Trash", { searchString: "Empty Trash" })).length).toBe(0); + }); + + test("POST /v1/mail-accounts/:mailAccountID/mailboxes/:mailboxPath/mail-bulk-actions/delete-all with permanent=true skips trash", async () => { + + await testIMAPClient.createMailbox("TestDeleteAllPerm"); + + await makeAPIRequest(`/v1/mail-accounts/${mailAccountID}/mailboxes/TestDeleteAllPerm/mails`, { + method: "POST", + authToken: session_token, + body: { + from: { name: "Bulk Test", address: "bulk@test.com" }, + to: [{ name: "Receiver", address: "receiver@test.com" }], + cc: [], + bcc: [], + subject: "Delete All Perm", + body: { text: "delete all perm test" } + }, + expectedBodySchema: MailsModel.Create.Response + }); + + const data = await makeAPIRequest(`/v1/mail-accounts/${mailAccountID}/mailboxes/TestDeleteAllPerm/mail-bulk-actions/delete-all`, { + method: "POST", + authToken: session_token, + body: { permanent: true }, + expectedBodySchema: MailBulkActionsModel.BulkDeleteAll.Response + }); + + expect(data.deletedCount).toBe(1); + expect((await testIMAPClient.getMails("TestDeleteAllPerm")).length).toBe(0); + expect((await testIMAPClient.getMails("Trash", { searchString: "Delete All Perm" })).length).toBe(0); + + await testIMAPClient.deleteMailbox("TestDeleteAllPerm"); + }); + + test("POST /v1/mail-accounts/:mailAccountID/mailboxes/:mailboxPath/mail-bulk-actions/delete-all reports a failed move as an error and keeps the mails", async () => { + + await testIMAPClient.createMailbox("TestDeleteAllFails"); + await makeAPIRequest(`/v1/mail-accounts/${mailAccountID}/mailboxes/TestDeleteAllFails/mails`, { + method: "POST", + authToken: session_token, + body: { + from: { name: "Bulk Test", address: "bulk@test.com" }, + to: [{ name: "Receiver", address: "receiver@test.com" }], + cc: [], + bcc: [], + subject: "Delete All Fails", + body: { text: "delete all fails test" } + }, + expectedBodySchema: MailsModel.Create.Response + }); + + // imapflow returns false on failure instead of throwing + const moveSpy = spyOn(ImapFlow.prototype, "messageMove").mockResolvedValue(false as any); + const copySpy = spyOn(ImapFlow.prototype, "messageCopy").mockResolvedValue(false as any); + try { + // Empty body on purpose, permanent defaults to false + const response = await API.getApp().request(`/v1/mail-accounts/${mailAccountID}/mailboxes/TestDeleteAllFails/mail-bulk-actions/delete-all`, { + method: "POST", + headers: { Authorization: `Bearer ${session_token}`, "Content-Type": "application/json" }, + body: "{}" + }); + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + message: "Failed to delete all mails" + }); + } finally { + moveSpy.mockRestore(); + copySpy.mockRestore(); + } + + expect((await testIMAPClient.getMails("TestDeleteAllFails")).length).toBe(1); + await testIMAPClient.deleteMailbox("TestDeleteAllFails"); + }); + test("POST /v1/mail-accounts/:mailAccountID/mailboxes/:mailboxPath/mails/bulk-move with empty uids fails validation", async () => { await makeAPIRequest(`/v1/mail-accounts/${mailAccountID}/mailboxes/INBOX/mails/bulk-move`, {