Batch deletion removes archived messages by search filter instead of by an explicit id list. It is the path for "everything that matches this query" — often far more messages than one page, and potentially a job that runs for minutes or hours without holding an HTTP request open.
Deletion in Bichon is permanent: there is no trash, recycle bin, or undo. A backup restore is the only way to recover deleted messages, and only from a restore point made before the deletion. Message restore pushes archived mail back to its source IMAP server; it is not an undo of deletion and cannot bring back a message that is no longer in the archive.
Two ways to delete
| Explicit selection | Every matching message | |
|---|---|---|
| WebUI | Check the rows you want, then Delete | Select the page header checkbox, then All N (tooltip: "Select all N matching results") → Delete in background |
| API | POST /api/v1/delete-messages | POST /api/v1/batch-delete |
| Execution | Synchronous, inside the request | Asynchronous job with progress, cancellation and history |
| Practical size | A page or a few pages | Any size |
| Permission failure | The whole request is refused | Accounts you cannot delete from are skipped and reported |
| Request budget | 60 s default (X-Bichon-Timeout-Seconds, max 600) | None — the job outlives the request |
Both paths delete through the same core routine, require the same data:delete permission, and are gated by the same dual-control policy on Enterprise.
The WebUI's "select all matching" mode exists only in the email search. Attachment search results cannot be batch-deleted.
Explicit IDs (synchronous)
POST /api/v1/delete-messages deletes exactly the IDs you list:
{
"12": ["2c5bb927-e095-40fa-8189-15a0c5ffb3d2"],
"27": ["8f14e45f-ceea-467a-9c1e-0b3d1c2a7e55"]
}The key is the account ID, not the mailbox ID, and each value is an envelope id — the UUID returned by search-messages. Search results carry both account_id and mailbox_id; group the message IDs by account_id before building the map.
The call is synchronous and all-or-nothing on permissions: every account named in the map must be deletable by the caller, or the whole request is refused. It is idempotent on IDs — IDs that no longer exist are skipped — so a 200 confirms the request was accepted, not that every ID matched a message. An unknown account ID returns 404.
Requirements
| Requirement | Detail |
|---|---|
| Edition | Community, Pro and Enterprise. Approval through dual control is Enterprise |
| Permission | data:delete on every source account the job would touch, or data:delete:all / system:root |
| Entry point | Email search page in the WebUI, or the REST API |
| Filter | The same filter object as POST /api/v1/search-messages — see Search & WebUI |
| Concurrency | One batch deletion job at a time, server-wide |
Preview: what would the job delete?
POST /api/v1/batch-delete/preview resolves the filter against your readable scope and reports the split between messages you may delete and messages you may only see. The request body is the same one you would submit to start a job:
{
"filter": {
"subject": "invoice",
"before": 1735689600000
},
"label": "Old invoices"
}The response breaks the matches down:
{
"total": 18432,
"matched": 20010,
"skipped_no_permission": 1578,
"account_ids": [1, 2],
"skipped_account_ids": [3],
"held_account_ids": []
}| Field | Meaning |
|---|---|
total | Messages the job would actually delete (you hold data:delete on those accounts) |
matched | Every message matching the filter in your readable scope |
skipped_no_permission | matched - total: matching messages on accounts you cannot delete from |
account_ids | Source accounts the job will touch |
skipped_account_ids | Readable source accounts whose matches are skipped |
held_account_ids | Accounts in the deletable scope that are under a legal hold — their presence makes the job fail |
retention_locked_account_ids | Accounts whose minimum-retention lock is active. Matching messages younger than the lock window will be skipped, not deleted |
The preview is advisory. It starts nothing, reserves nothing, and its counts can differ from the job's by whatever mail was archived between the preview and the start. The job itself enumerates the filter once, when it starts.
Timestamps (since, before, internal_date_*, ingest_*) are epoch milliseconds, like every other Bichon API timestamp.
Permissions
Deletion is authorized on the source account, not on a view or on a search scope.
| Principal | Can delete when |
|---|---|
system:root | Any account |
data:delete:all | Any account |
data:delete | Accounts it is granted on |
| AccountManager role | Its assigned accounts (the role carries data:delete) |
| View access | Only if the caller also holds data:delete on each source account — a view is a read grant, and deleting through a view is not a lesser act than deleting through the source |
Consequences worth knowing:
- The asynchronous path is partial by design. If the filter matches messages on five accounts but you hold
data:deleteon three, the job deletes on those three and reports the other two as skipped. It does not fail the whole request. - The synchronous path is all-or-nothing.
POST /api/v1/delete-messageschecksdata:deleteon every account named in the request and refuses the entire batch if one is missing. The WebUI disables the delete action when the selection includes such an account. - A job is personal. The creator sees their own jobs;
system:rootsees every job. Another user with delete rights on the same accounts cannot list or cancel your job, and gets403if they ask for it by id. - Naming an unreadable account does not error. The account is dropped from the search scope, which can leave the result empty; the delete then answers
403("no account matching this search"). - Permissions are not re-checked per batch. Revoking
data:deletewhile a job runs does not stop it. A legal hold placed mid-run does stop the affected batches (below).
The job lifecycle
- Scope resolution. The filter is combined with your readable scope. A view is expanded into its source accounts and its per-source filters.
- Authorization split. Accounts where you lack
data:deleteare removed from the job and reported in the preview (skipped_account_ids). - Enumeration, once. When the worker starts, it resolves the filter to a concrete set of message ids. This is the point-in-time set the job will delete. Mail archived afterwards is not touched, even if it matches the filter.
- Batches of 500. For each account, ids are deleted in chunks of 500. One batch is one core delete call, one backup write-gate acquisition, one persisted progress update and its own audit records.
- Per-batch failure isolation. A failed batch is counted in
failed; the job continues with the next batch. The final status isfailedif any batch failed, anderrorcarries the last error message. - Final state is flushed. Job creation, every status transition and the final status are flushed to disk immediately. Progress counters ride the normal write-ahead path, so a hard crash can lose the last few counters but never the record that the deletion happened.
Only one job runs at a time across the whole server. A second request is refused with 429 Too Many Requests and a message naming the running job — it is not queued. The limit exists so two large deletions cannot thrash the index writer and the backup gate.
Statuses
| Status | Meaning |
|---|---|
running | Enumerating or deleting; processed advances |
cancelling | Cancellation requested; the worker stops before the next batch |
cancelled | Stopped by request |
success | Every batch completed |
failed | At least one batch failed; deleted / failed describe the outcome |
interrupted | The server stopped while the job was running |
Cancellation
POST /api/v1/batch-delete/{job_id}/cancel sets the job to cancelling. The worker checks the flag between batches, so the batch in flight completes. Already-deleted messages are not restored. Cancelling a finished job returns 400.
Server restart
Jobs are persisted in the memdb database, so history survives a restart. A job that was running or cancelling at shutdown is marked interrupted on startup and is never resumed automatically — the exact partial state cannot be known, and re-running is an explicit user decision. The resolved scope (view expansion and filters) is kept in memory only; the persisted job keeps the original filter, account ids and per-account counts.
History
The last 30 jobs are kept, newest first. Finished jobs are evicted as new ones start; running jobs are never evicted. Each record carries the filter, label, account ids, total / processed / deleted / failed, per-account progress, timestamps, the requester, and — for approved deletions — the approver and approval request id.
The WebUI polls only while a job is active (every 1.5 s). When it ends, the progress card disappears, a toast reports the outcome, and the result list is refreshed. Closing the page does not stop the job.
Tombstones: why deleted mail does not come back
Deleting from the archive is permanent, but gap-fill and full re-sync discover local holes by comparing the server's UID list with the local index. Without a record, a deleted message looks exactly like a failed download and would be fetched again on the next repair run.
Before deleting, Bichon records the message's source UID as a tombstone:
- One compressed bitmap per source mailbox under
<root>/tombstones/<account_id>/<mailbox_id>. - Self-describing format: magic, version, account/mailbox ids,
UIDVALIDITY, payload length and CRC32. - Written atomically (temp file, fsync, rename) and before the messages are deleted. A crash can therefore leave a tombstone for a message that still exists — harmless — but never a committed deletion without one.
- Gap-fill and full re-sync remove tombstoned UIDs from their download candidates.
Limits of the mechanism:
- IMAP-sourced messages only. Imported messages and SMTP journal messages have
uid == 0: there is no remote copy to re-download, so no tombstone is written. - No
UIDVALIDITY, no tombstone. If the mailbox has no recordedUIDVALIDITY, Bichon cannot tie a UID to a generation and skips the record. - Tombstones belong to one
UIDVALIDITYgeneration. If the server rebuilds the mailbox (newUIDVALIDITY), the old bitmap is ignored and dropped; messages that still exist in the rebuilt mailbox can be downloaded again under their new UIDs. - Tombstones are included in backups, so a restore does not re-download messages that were deleted before the backup was taken. A restore from a point before the deletion can bring those messages back — that is the point of restoring.
Minimum retention (Enterprise deletion lock)
When an account has min_retention_days, messages still inside that window are skipped, not deleted. The job reports them in skipped_locked; the preview lists the affected accounts in retention_locked_account_ids so the operator sees the warning before confirming.
The lock is enforced again inside the single delete path, so a synchronous delete that includes a protected message is refused as a whole. See Minimum retention.
Legal hold (Enterprise)
A legal hold freezes an account against data loss, and batch deletion honours it at two points:
- Before the job starts: if any account the job would touch is held, the request is refused with
403and the message names the held accounts. The preview reports them inheld_account_idsso the operator sees the problem before confirming. - Per batch: the core delete re-checks the hold. If a hold is placed while the job is running, subsequent batches for that account fail and are counted in
failed; other accounts continue.
Retention purging, single-message deletes and GDPR erasure all honour the same hold.
Dual control (Enterprise)
When message.delete is listed in the dual-control policy, both delete endpoints become approval requests instead of executions:
POST /api/v1/batch-deleteanswers202 Acceptedwith{ "ok": false, "pending": true, "request_id": …, "expires_at": … }and starts no job.- The exact id set is frozen at request time — the same enumeration a job would have done. The approver signs a concrete list, not a filter that keeps matching new mail.
- Large sets do not fit in the decision ledger, so the ids live in a file under
<root>/approval-details/. The ledger row keeps the count, the per-account split and a SHA-256 of the file; the file is written atomically and verified before execution. - Re-submitting the identical pending selection is refused rather than queueing a duplicate.
- Approving runs the same worker as an ordinary batch job: chunks of 500, cancellation, tombstones, per-batch audit.
- The job belongs to the requester (it appears in their history), while audit records name the approver as the executor. The job also stores the approval request id, so an auditor can walk from the decision to every batch it produced.
- The decision window is 72 hours by default. Self-approval is blocked; break-glass, if enabled, requires a server-enforced wait.
Audit trail
Every successful delete batch emits audit events. The decision is made per batch:
- A batch of up to
BICHON_AUDIT_DETAIL_THRESHOLDmessages (default 100) produces oneemail.deletedrecord per message, carrying the subject, a snapshot of the message, the job id and the search filter. - A larger batch produces a single
email.batch_deletedrecord that references a JSONL detail file (relative path, size, SHA-256) held under<root>/audit/detail/. The file is fetchable on demand and its digest is bound into the audit hash chain. Because a job deletes in batches of 500, a large job normally produces one such record per 500 messages. - A failed batch emits nothing: the trail never claims a deletion that did not happen.
- Records are emitted only on Pro and Enterprise, where the tamper-evident audit log is available. They can be filtered by job id, and SIEM forwarding carries them in the
deletecategory.
What deletion actually removes
- The message's search index entry and its attachment index entries. Tags and thread reconstruction are derived from those entries, so they disappear with the message.
- Source UID tombstones (above), so sync does not recreate it.
- The content blobs become unreferenced. Because blobs are content-addressed and shared, they are reclaimed only when both email and attachment reference counts reach zero, and the space is actually released by the background GC when it compacts a segment. Until then, the bytes may still exist on disk even though no search or API call can reach the message.
Recovery
There is no undo. What you can do instead:
| Situation | Action |
|---|---|
| The job is still running | Cancel it; the current batch finishes, the rest is skipped |
| You deleted a message by mistake | Restore a backup made before the deletion. Nothing else recovers it |
| The message still exists on the source server | Bichon will not re-download it while the tombstone is valid; re-import it explicitly if you want it back |
| You want mail moved out of the source server | That is source mailbox cleanup, a reversible move — unrelated to archive deletion |
Retention (retention_days) is a separate, scheduled purge — permanent and irreversible like manual deletion. A manual batch deletion and retention can both remove messages, and both are covered by legal hold.
API reference
| Method | Path | Notes |
|---|---|---|
POST | /api/v1/delete-messages | Synchronous explicit-ID delete. Body: { "<account_id>": ["<envelope_id>", …] } — the key is the account ID, not the mailbox ID |
POST | /api/v1/batch-delete/preview | Counts and account split; no delete permission required, only read access |
POST | /api/v1/batch-delete | Starts a job (or queues an approval). Body: { "filter": {…}, "label": "…" } |
GET | /api/v1/batch-delete | Job history — your own jobs; system:root sees all |
GET | /api/v1/batch-delete/{job_id} | Status, progress and per-account counts |
POST | /api/v1/batch-delete/{job_id}/cancel | Request cancellation, applied between batches |
An empty filter is refused when your scope is unrestricted ("a batch deletion needs at least one search condition") — an accidental click must not wipe the archive. A caller whose scope is limited by account grants may submit an empty filter, which means "everything I can see".
Common errors:
| Status | Cause |
|---|---|
400 | Empty filter without a constrained scope; malformed filter; cancelling a finished job |
403 | No data:delete on any matching account; a held account in scope; a protected message in a synchronous delete; a job that belongs to someone else |
404 | Unknown job id or account ID |
429 | Another batch deletion is already running |