Bichon
Email archiving service with continuous IMAP sync and full-text search. Docs: github.com/rustmailer/bichon
- URL: tailnet only — see Tailnet-only apps
- Port: internal (Caddy proxy)
- Data:
/opt/bichon/data(internal store),/var/lib/bichon-archive(EML mirror) — both backed up - Timers:
bichon-archive.timer(hourly archive),bichon-uidvalidity-watch.timer(hourly rebuild alert)
Deploy
Section titled “Deploy”auberge deploy bichonBare-metal (no Docker). Requires Tailscale deployed first.
Required config
Section titled “Required config”| Key | Purpose |
|---|---|
bichon_encryption_password |
Encrypts credentials and metadata DB |
bichon_subdomain |
Subdomain for HTTPS access |
bichon_api_token |
Bearer token for the hourly archive timer |
bichon_api_token: mint in Bichon’s UI after first deploy, paste into config.toml, re-run.
Default credentials: admin / admin@bichon. Change after first login.
First-time setup:
- Add account via Accounts → Add account in the UI.
- Reconcile folders:
Terminal window auberge bichon reconcile-folders --host <hostname> --apply - Seed the archive immediately:
Terminal window sudo systemctl start bichon-archive.service
Backup: auberge backup create --apps bichon rsyncs /var/lib/bichon-archive and /opt/bichon/data. The timer must have run at least once before the first backup. bichon-archive.timer is stopped for the duration of the window and started again after, so an hourly tick cannot restart bichon over a live copy of its store (ADR-0032). A restored store brings accounts, the API-token registry, tags, and search back with it; the archive replay below remains the path for purge recovery. See ADR-0006 and ADR-0031.
The archive holds one .eml per message (byte-exact), a .meta.json sidecar recording its folder and its message_id, and one tags.json per account mapping RFC 5322 Message-ID to tags. tags.json is rewritten in full on every archive run — tags are mutable, so they are snapshotted rather than captured once.
The <envelope-id> in a filename is storage, not identity: Bichon mints a fresh envelope identifier on re-import, so one message can occupy several .eml files. message_id is the message’s canonicalized RFC 5322 Message-ID, read from the body; a body with no such header is keyed by its sha256. Count distinct message_id, never files:
# messages in the archive, not copiesssh <hostname> "sudo find /var/lib/bichon-archive -name '*.meta.json' \ -exec jq -r .message_id {} + | sort -u | wc -l"The run skips a message it already holds on that identity too, not on the filename. <envelope-id>.eml exists answers only for ids Bichon has not re-minted since; after the v2 upgrade regenerated every one as a UUID it reported all 9,394 archived bodies as absent, and a single tick wrote 825 duplicates (ADR-0019). Filenames are consequently mixed for good — old numeric ids beside new UUIDs — which costs nothing, because nothing reads a filename as identity. Each account logs the two decisions separately:
ssh <hostname> "journalctl -u bichon-archive.service -n 40 | grep processed="# account=x@riou.one processed=0 skipped=714 deduped=0 failures=0skipped is a filename match and deduped an identity match backed by a byte-identical body. A different body that reuses an archived Message-ID is another message, so it is published alongside rather than deduped. A sustained non-zero deduped therefore means Bichon’s envelope ids moved and the archive is re-recognising bodies it already holds. That costs one download per re-listed message and clears within one overlap window (24h) as the cursor advances past them.
Sidecars written before ADR-0013 carry no message_id. The next bichon-archive.service run repairs every one of them and drops the inert tags field ADR-0012 left behind; until it has, gate 3 of bichon-expunge.sh refuses to run.
A payload that is not a message is refused rather than archived — Bichon answers 200 with zero bytes when an envelope’s blob store entry is empty, which curl --fail reads as success. One already in the archive is refetched by the envelope id in its filename on the next run (ADR-0015). A body Bichon can no longer serve fails the unit on every run and is named in the journal; clearing it means deleting both the body and its sidecar, since a sidecar with no body fails the run too.
# bodies the archive should have refused (expect no output)ssh <hostname> "sudo find /var/lib/bichon-archive -name '*.eml' -empty"UIDVALIDITY rebuild alert
Section titled “UIDVALIDITY rebuild alert”When an Upstream Mailbox changes a folder’s UIDVALIDITY, Bichon deletes that folder’s envelopes and blobs from the internal store and refetches. Mail already expunged upstream cannot be refetched — its searchability and tags are gone until you replay the archive. Bichon logs this at info level and carries on.
bichon-uidvalidity-watch.timer reads bichon.service’s journal hourly and records every occurrence. It shows up as a failed unit:
systemctl --failedsystemctl status bichon-uidvalidity-watch.service # names the folder and whenThe alert is latched: it is reported on every run until you acknowledge it, because systemd would otherwise clear the failed state on the next tick. The full record is /var/lib/bichon-uidvalidity-watch/rebuilds.log.
| Exit | Meaning |
|---|---|
| 0 | no rebuild recorded |
| 1 | a rebuild is recorded and unacknowledged |
| 2 | the journal could not be read |
To respond: restore the affected folder per Restore ordering below, then acknowledge — deleting the file is the acknowledgement.
sudo rm /var/lib/bichon-uidvalidity-watch/rebuilds.logsudo systemctl start bichon-uidvalidity-watch.service # exits 0, clears the failed stateSee ADR-0014. The alert is passive by design: it carries no push channel, so it reaches you when you look at the Host or Cockpit.
Restore ordering (do not skip steps):
auberge deploy bichon, then add the account via Accounts → Add account — restore does not create accounts.- Let folders sync, then reconcile:
auberge bichon reconcile-folders --host <hostname> --apply. Bichon only imports into folders it already knows. - Restore
/var/lib/bichon-archivefrom restic if it is not still on the Host. - Run
examples/bichon-restore.sh:Terminal window BICHON_API_TOKEN=… bash examples/bichon-restore.sh \--host http://127.0.0.1:15630 --account you@example.com
The script imports every .eml into the folder its sidecar records, then replays tags.json onto the imported messages with action: Add, so re-running the tag replay never removes anything. --dry-run prints the folder and tag-set plan without touching the API. It reports imported / tagged / skipped / failed counts and exits non-zero on any failure.
Archived-then-expunge ordering (do not skip steps):
- Folders ticked in Bichon UI,
bichon.servicesyncing. bichon-archive.timerran successfully — checkjournalctl -u bichon-archive.service.- Archive is off-host —
auberge backup sync, then confirm it landed:ExitTerminal window auberge backup verify --app bichon0means the newest offsite snapshot contains the archive and is younger than 24h. Anything else: stop, do not expunge. See backup verify. - Operator expunges manually (e.g.
himalaya).
Reference script: examples/bichon-expunge.sh turns the ordering above into five gates and, once they all pass, executes the expunge.
bash examples/bichon-expunge.sh --host <hostname> --account you@example.com| Gate | Assertion |
|---|---|
| 1 | auberge backup verify --app bichon exits 0 |
| 2 | last bichon-archive.service run succeeded, less than 3h ago |
| 3 | archived .eml count >= IMAP count, scoped to --folder |
| 4 | summary: exact himalaya commands, message count, snapshot evidence |
| 5 | operator types the folder name at a prompt |
Defaults: --folder INBOX, --window-days 90, --archive-path /var/lib/bichon-archive. On a TTY, a missing --host is chosen from auberge host list and a missing --account from himalaya account list. Gate 3 also aborts if anything in the folder already carries the \Deleted flag, since the expunge would take it along.
A preflight runs before the gates, resolving one value at a time: tools on PATH (all missing ones reported at once), auberge backup verify present, himalaya holding at least one configured account, ssh reachability, then the Email Archive root.
--account must be the mailbox email address. himalaya account names are arbitrary labels, but bichon keys archive directories by email (sanitize_email in bichon-archive.sh.j2) and the script passes one value to both — so the himalaya account has to be named after the address. The menu offers only accounts present on both sides; a mismatched --account is rejected by name before gate 1.
Deletion is himalaya flag add … deleted followed by himalaya folder expunge — messages are removed in place, not moved to Trash, so mailbox quota is actually reclaimed.
Expunge Sweep: --sweep walks every eligible (account, Synced Folder) pair on the Host with one window, instead of one operator-chosen pair (ADR-0007, amendment 2026-08-03). Excludes --folder.
bash examples/bichon-expunge.sh --sweep --host <hostname> --window-days 90Repeat --account to narrow the sweep to those accounts, every Synced Folder of each — the folder set still comes from Account Reconcile, so only the account set changes:
bash examples/bichon-expunge.sh --sweep --host <hostname> \ --account you@example.com --account other@example.comA scoped sweep reports only the accounts it was given: an account name himalaya does not know aborts before any gate runs, rather than vanishing from a report the operator would have to notice was short a row. Without --sweep, a second --account is refused — one target takes one account.
Gates 1–2 run once for the Host; gate 3 runs per pair, and a failing pair is skipped as a named finding instead of aborting the rest (coverage gap, pre-existing \Deleted mail, unkeyed sidecar, a listing the tools could not produce). An empty window, reconcile drift, or an account missing on either the himalaya or the archive side is a benign skip. Gates 4–5 become one summary table — every pair, its status, its count — and two typed checkpoints:
- scope — type the Bichon Host name
- magnitude — type the grand message total from the summary
Exit codes: 0 clean sweep, 1 at least one finding (read the report), 2 operational error or an empty target set. A folder left with \Deleted mail by a mid-sweep failure is refused by the next run’s pre-existing-flag check, so a partial sweep cannot compound silently.
--sweep --no-input is a cron-safe fleet-wide coverage verification: every gate runs, nothing is expunged, exit 1 flags that a finding needs attention.