Bichon Docs

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 selectionEvery matching message
WebUICheck the rows you want, then DeleteSelect the page header checkbox, then All N (tooltip: "Select all N matching results") → Delete in background
APIPOST /api/v1/delete-messagesPOST /api/v1/batch-delete
ExecutionSynchronous, inside the requestAsynchronous job with progress, cancellation and history
Practical sizeA page or a few pagesAny size
Permission failureThe whole request is refusedAccounts you cannot delete from are skipped and reported
Request budget60 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

RequirementDetail
EditionCommunity, Pro and Enterprise. Approval through dual control is Enterprise
Permissiondata:delete on every source account the job would touch, or data:delete:all / system:root
Entry pointEmail search page in the WebUI, or the REST API
FilterThe same filter object as POST /api/v1/search-messages — see Search & WebUI
ConcurrencyOne 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": []
}
FieldMeaning
totalMessages the job would actually delete (you hold data:delete on those accounts)
matchedEvery message matching the filter in your readable scope
skipped_no_permissionmatched - total: matching messages on accounts you cannot delete from
account_idsSource accounts the job will touch
skipped_account_idsReadable source accounts whose matches are skipped
held_account_idsAccounts in the deletable scope that are under a legal hold — their presence makes the job fail
retention_locked_account_idsAccounts 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.

PrincipalCan delete when
system:rootAny account
data:delete:allAny account
data:deleteAccounts it is granted on
AccountManager roleIts assigned accounts (the role carries data:delete)
View accessOnly 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:delete on 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-messages checks data:delete on 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:root sees every job. Another user with delete rights on the same accounts cannot list or cancel your job, and gets 403 if 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:delete while a job runs does not stop it. A legal hold placed mid-run does stop the affected batches (below).

The job lifecycle

  1. Scope resolution. The filter is combined with your readable scope. A view is expanded into its source accounts and its per-source filters.
  2. Authorization split. Accounts where you lack data:delete are removed from the job and reported in the preview (skipped_account_ids).
  3. 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.
  4. 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.
  5. Per-batch failure isolation. A failed batch is counted in failed; the job continues with the next batch. The final status is failed if any batch failed, and error carries the last error message.
  6. 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

StatusMeaning
runningEnumerating or deleting; processed advances
cancellingCancellation requested; the worker stops before the next batch
cancelledStopped by request
successEvery batch completed
failedAt least one batch failed; deleted / failed describe the outcome
interruptedThe 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 recorded UIDVALIDITY, Bichon cannot tie a UID to a generation and skips the record.
  • Tombstones belong to one UIDVALIDITY generation. If the server rebuilds the mailbox (new UIDVALIDITY), 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.

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 403 and the message names the held accounts. The preview reports them in held_account_ids so 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-delete answers 202 Accepted with { "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_THRESHOLD messages (default 100) produces one email.deleted record 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_deleted record 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 delete category.

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:

SituationAction
The job is still runningCancel it; the current batch finishes, the rest is skipped
You deleted a message by mistakeRestore a backup made before the deletion. Nothing else recovers it
The message still exists on the source serverBichon 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 serverThat 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

MethodPathNotes
POST/api/v1/delete-messagesSynchronous explicit-ID delete. Body: { "<account_id>": ["<envelope_id>", …] } — the key is the account ID, not the mailbox ID
POST/api/v1/batch-delete/previewCounts and account split; no delete permission required, only read access
POST/api/v1/batch-deleteStarts a job (or queues an approval). Body: { "filter": {…}, "label": "…" }
GET/api/v1/batch-deleteJob 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}/cancelRequest 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:

StatusCause
400Empty filter without a constrained scope; malformed filter; cancelling a finished job
403No 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
404Unknown job id or account ID
429Another batch deletion is already running

On this page