Getting started
Every account brev archives is an entry in config.toml, and brev account manages them.
account add gives @gmail.com addresses a host/port preset (imap.gmail.com:993); any other domain takes --host (and --port if it is not 993). It verifies the account before writing it — resolve, connect, log in — and refuses to write a config entry it could not make sync, naming the stage that failed; --no-verify writes one anyway. On success it creates the account's mail.sqlite3 and tells a running daemon to pick the account up, so the first sync starts within seconds.
Credentials can be stored inline in config.toml (written chmod 600), read from an environment variable with --password-env, or produced at sync time by a command with --password-command 'pass show email/you@gmail.com'.
The first sync of a decades-old Gmail account takes a long time: Gmail throttles IMAP downloads to roughly 2.5 GB a day per account, so a large account spans several days. It is interruptible — it records where it stopped and resumes there — and brev can be opened against the archive while it fills.
Gmail accounts
Both authentication modes are first-class: a Gmail app password over plain IMAP LOGIN, or OAuth2 / XOAUTH2 with a one-time browser consent. A Google Workspace address on your own domain is a Gmail account too; it takes --host imap.gmail.com.
App password. The simplest path.
- Turn on 2-Step Verification on the Google account; app passwords need it.
- Create an app password at
myaccount.google.com/apppasswordsand copy the 16-character token. It is not your Google account password. - Make sure IMAP is enabled: Gmail Settings → Forwarding and POP/IMAP → Enable IMAP.
- Run
brev account add you@gmail.com, choose App password, and paste the token.
OAuth2. No long-lived password on disk; brev holds a refresh token for the gmail.modify scope, revocable from your Google account at any time. It needs a Google Cloud OAuth client, created once:
- In the Google Cloud console, create a project and enable the Gmail API.
- Configure the OAuth consent screen with the
https://www.googleapis.com/auth/gmail.modifyscope. While it is in Testing, add every address you will archive under Test users. - Create an OAuth client ID of type Desktop app and keep its client id and client secret.
brev opens Google's consent page in your browser (and prints the URL, for a headless machine) and receives the authorization on a 127.0.0.1 redirect. Sign in as the address you are adding and approve; brev reports authorized; cached refresh token for you@gmail.com, and no later sync needs the browser. Once one account uses OAuth, account add offers to reuse its client for the next, so the client id and secret are typed once; each address still approves its own consent screen.
Running
With no subcommand, brev opens the interactive client on every account under the archive root. The same binary owns the maintenance verbs.
brev daemon holds daemon.lock for its lifetime, binds a control socket at $XDG_RUNTIME_DIR/daemon.sock, and syncs every configured account on its own schedule: Gmail accounts through history.list polling, IMAP accounts through IDLE plus polling. A running client attaches to that socket and refreshes when a pass changes anything, keeping the selected message and its screen row. brev sync exits as a no-op while the daemon holds the lock, unless given --force.
Composing, replying and forwarding happen in the client. A sent message is frozen to its exact RFC 5322 bytes at queue time and delivered at most once: the daemon drains the outbox as part of its loop, the client drains in-process when no daemon is running, and brev drain is the manual fallback. A row whose delivery cannot be established after a crash ends in needs_attention and is never retransmitted.
The archive
Per account: one SQLite database plus a content-addressed attachment store. The layout on Linux:
~/.local/share/brev/ # $XDG_DATA_HOME/brev; the backup root
archive/ # --root overrides this subtree
<email>/ # one directory per archived address
mail.sqlite3 # the account's database
attachments/ # content-addressed blob store
<first-2-hex>/
<full-sha256> # one file per unique attachment, no extension
state/
overlay.sqlite3 # the client's own state
search.sqlite3 # disposable cross-account search index
logs/sync.log
~/.config/brev/config.toml # accounts, auth, sync settings, preferences
Each address is self-contained: its mail.sqlite3 and its attachments/ directory together are the whole archive for that address. Backup is copying the directory. The database is opened in WAL mode, so reading it while a sync writes is safe.
The schema is built by numbered migrations, applied in ascending order and each recorded once in schema_migrations; the schema version of a database is MAX(version). A shipped migration is never edited, so every archive, however old, upgrades in place. The full table-by-table contract is archive/docs/SCHEMA.md in the brev repository.
The overlay
The client never writes the archive. Everything mutable it records lives in state/overlay.sqlite3: local read markers, local stars, the server-backed message projection (seen, flagged, labels, mailbox membership, trash state), queued server-bound mutations with their undo data, drafts, outbox rows, cached OAuth tokens and remembered UI state. It never stores archive content. Rows are keyed by (account_email, message_key), where message_key is the message's Message-ID when it has one, else its raw_sha256. --state <PATH> overrides its location.
Key bindings
Every binding has a menu item painting the same shortcut; nothing is reachable by key alone. F1 or ? opens the key help, generated live from the action registry.
| key | action |
|---|---|
Enter | Open the selected message in the reader pane |
Esc | Close the innermost open surface (menu → modal → search → reader pane) |
↑ / ↓, k / j | Move the selection (the pane follows while open) |
PgUp / PgDn, Home / End | Page / jump the list (scroll the reader pane instead while it is open) |
→ / ← | Expand / collapse the selected thread |
Tab / Shift+Tab | Focus the next account / clear account focus to all accounts |
m / u | Mark the selection read / unread |
t | Mark the whole selected thread read |
s / S | Star / unstar the selection |
a | Archive the selection |
A | Archive every inbox message from the selected sender |
d / D | Trash / untrash the selection |
! / N | Report the selection as spam / not spam |
v, l / L | Open the move-to-mailbox, add-label / remove-label target picker |
Ctrl+Z | Undo the last mutation |
h | Toggle raw stored headers in the reader pane |
y / g / i | Copy the body / sender / Message-ID |
f or / | Open the search field |
q | Quit |
F1 / ? | Key-binding help |
Every server verb records a mutation intent the replay loop sends to the server, with the change shown in the list immediately; an item greys out when it would be a no-op or when the account does not support the verb — an IMAP account is offered no label verbs. The list sorts by date only, newest-first or oldest-first.
Search
f or / opens the search field. The query language is Gmail-ish; operators AND together. A malformed query is a status-bar message, never a crash.
| operator | meaning |
|---|---|
from:TEXT / to:TEXT | Sender / a recipient matches |
subject:TEXT | Subject contains |
before:DATE / after:DATE | Dated before / on or after the given local day |
label:X / mailbox:X | Carries label / in mailbox (overrides the default Sent/Spam/Trash exclusions) |
has:attachment | Carries at least one attachment |
is:flagged | The IMAP \Flagged bit is set |
account:ADDR | Restrict to one account; repeat to union several |
is:starred | brev's star: local star or projected server flag |
is:read / is:unread | brev's current read state: unread iff the server says unseen and no local read marker exists |
| bare words | Full-text match over the message (FTS5) |
from:alice after:2024-01-01 # from Alice, 2024 or later is:unread has:attachment # unread with an attachment account:me@example.com is:starred # starred, in one account only subject:invoice label:Receipts # subject "invoice" within a label "exact phrase" # full-text phrase match
Agents
brev embeds no model. An [agents] table in config.toml names the runner family and, per task — condense today — the model, the command argv template and the prompt. brev substitutes {model} into the template, launches the command off the UI thread, feeds it the prompt and the message on stdin, and streams its stdout into the reader pane. A missing binary, an empty template or a non-zero exit is one failure event, never a hang. Without an [agents.condense] table the verb reports no agent configured.
[agents.condense]
command = "claude -p --model {model}"
model = "<model id>"
prompt = "Condense this thread to five lines."
The archive needs no agent verb to be useful to an agent: a coding agent in a shell can run sqlite3 against mail.sqlite3 directly, with brev not running.