Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ export namespace MailBulkActionsModel.BulkDelete {
export type Response = z.infer<typeof Response>;
}

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<typeof Body>;

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<typeof Response>;
}

export namespace MailBulkActionsModel.BulkSetFlags {

/** Only the provided flags are changed; `true` sets the flag, `false` clears it. */
Expand Down
14 changes: 10 additions & 4 deletions src/api/versions/v1/routes/mail-accounts/mailboxes/mails/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
73 changes: 59 additions & 14 deletions src/utils/mails/backends/imap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });
Expand All @@ -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();
}
Expand All @@ -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.
Expand All @@ -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();
}
Expand All @@ -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<number> {
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<number[]> {
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');
Expand Down Expand Up @@ -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<T>(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;
Expand Down
Loading
Loading