skip to content

Docs

Adding an account, running brev, where the mail lives, the keys, the search operators, and the agent configuration.

Getting started

Every account brev archives is an entry in config.toml, and brev account manages them.

$brev account add you@gmail.com # prompts for auth method and credential
$brev sync you@gmail.com # first capture; resumable, runs for hours on a big account
$brev # open the client

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'.

$brev account ls # each account's host, port and auth mode
$brev account reauth you@gmail.com # repair the credentials of a configured account
$brev account rm you@gmail.com # remove the config entry; never deletes archived mail

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.

  1. Turn on 2-Step Verification on the Google account; app passwords need it.
  2. Create an app password at myaccount.google.com/apppasswords and copy the 16-character token. It is not your Google account password.
  3. Make sure IMAP is enabled: Gmail Settings → Forwarding and POP/IMAP → Enable IMAP.
  4. 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:

  1. In the Google Cloud console, create a project and enable the Gmail API.
  2. Configure the OAuth consent screen with the https://www.googleapis.com/auth/gmail.modify scope. While it is in Testing, add every address you will archive under Test users.
  3. Create an OAuth client ID of type Desktop app and keep its client id and client secret.
$brev account add you@gmail.com --auth xoauth2 --client-id '<id>.apps.googleusercontent.com' --client-secret '<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 # open the client
$brev --root /path/to/data # a different archive root
$brev sync you@gmail.com # sync one account
$brev check # verify every archived account
$brev daemon # the live sync daemon, in the foreground
$brev service install # install and start the user service for brev daemon
$brev service status # daemon and service unit state
$brev drain # drain queued outbox work for every account

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.

keyaction
EnterOpen the selected message in the reader pane
EscClose the innermost open surface (menu → modal → search → reader pane)
↑ / ↓, k / jMove the selection (the pane follows while open)
PgUp / PgDn, Home / EndPage / jump the list (scroll the reader pane instead while it is open)
→ / ←Expand / collapse the selected thread
Tab / Shift+TabFocus the next account / clear account focus to all accounts
m / uMark the selection read / unread
tMark the whole selected thread read
s / SStar / unstar the selection
aArchive the selection
AArchive every inbox message from the selected sender
d / DTrash / untrash the selection
! / NReport the selection as spam / not spam
v, l / LOpen the move-to-mailbox, add-label / remove-label target picker
Ctrl+ZUndo the last mutation
hToggle raw stored headers in the reader pane
y / g / iCopy the body / sender / Message-ID
f or /Open the search field
qQuit
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.

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.

operatormeaning
from:TEXT / to:TEXTSender / a recipient matches
subject:TEXTSubject contains
before:DATE / after:DATEDated before / on or after the given local day
label:X / mailbox:XCarries label / in mailbox (overrides the default Sent/Spam/Trash exclusions)
has:attachmentCarries at least one attachment
is:flaggedThe IMAP \Flagged bit is set
account:ADDRRestrict to one account; repeat to union several
is:starredbrev's star: local star or projected server flag
is:read / is:unreadbrev's current read state: unread iff the server says unseen and no local read marker exists
bare wordsFull-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.