Terminal-first operations for existing Thunderbird and Betterbird profiles.
tb is for people who already trust Thunderbird or Betterbird as the source of truth, but need a sharper operator layer for search, audits, scripting, and coding agents. It reads the profile you already have, keeps a fast local cache, and can send mail headlessly through the identities already configured in the mail client.
Recommended on Linux and macOS:
curl -fsSL https://raw.githubusercontent.com/avikalpa/thunderbird-cli/main/install.sh | sh
tb doctorFast update:
tb update
tb update --checkIf tb doctor reports missing NSS runtime libraries on Linux, install the usual runtime packages for your distribution. On Debian and Ubuntu that is typically:
sudo apt install libnss3 libnspr4Manual/source install remains available when you want the local checkout workflow or a platform-specific build:
git clone https://github.com/avikalpa/thunderbird-cli ~/gh/thunderbird-cli
cd ~/gh/thunderbird-cli
go build -o bin/tb ./...
./bin/tb doctorgo build leaves a binary in ./bin/tb, install.sh writes to ~/.local/bin by default, and tb update replaces only the copy it is running from. Since interactive and non-interactive shells often have different PATHs, ssh host 'tb ...' and your terminal can end up running different builds.
Pick one canonical path and install over it. scripts/install-local.sh builds the working tree and does this for you:
./scripts/install-local.sh # -> /usr/local/bin/tb
TB_INSTALL_DIR=~/.local/bin ./scripts/install-local.sh/usr/local/bin is the default because it is on the non-interactive PATH. tb doctor lists every tb it can find and warns when they disagree.
tb doctor checks every one of these on the machine in front of you; trust it over this list.
| Need | Required for |
|---|---|
| An existing, populated Thunderbird/Betterbird profile | everything — tb reads mail, it never creates accounts |
Linux x86-64/arm64, cgo, NSS (libnss3, libnspr4) |
direct send, credential storage, authcheck, sentcheck |
| macOS / Windows build | read, search, cache only (tb update unsupported on Windows) |
A Thunderbird-family client on PATH (Betterbird recommended) or Flatpak |
--sync only |
A GUI session, a running client to join, or Xvfb |
--sync on a headless host |
Reading and searching an already-synced profile need no mail client running at all.
Thunderbird and Betterbird are strong interactive mail clients. They are not strong operator CLIs.
Common pain points:
- repeated mail searches are slow and difficult to automate
- evidence collection through the GUI does not compose well with scripts or coding agents
- profile-stored credentials are trapped inside the client
- headless send is awkward even when the account is already configured
- local mail stores are too important to poke at with ad hoc one-off scripts
tb solves that without replacing Thunderbird or Betterbird:
- Thunderbird or Betterbird remains the source of truth
- your configured accounts stay where they are
tbadds indexing, search, read, sync, and controlled send on top
3.x is the release line where tb stopped assuming PostgreSQL for first-run usability.
The major design changes are deliberate:
- SQLite is now the default cache backend
- PostgreSQL is still supported, but only when explicitly requested
tb doctor,tb features, andtb updateare now first-class commands- release archives and the installer script are part of the product surface
- runtime capability reporting is explicit, especially around direct secret-backed send support
That makes the tool easier to install, easier to carry to another machine, and easier to understand when a platform-specific edge case blocks a feature.
tb does five things well:
- discover Thunderbird and Betterbird profiles automatically
- ingest mail into a fast local cache for repeated search
- read matching messages and threads from local mail stores
- trigger sync through the configured mail client when needed
- send headlessly from profile-backed identities when the build and runtime support it
Cold-start search is now part of that contract. If a profile cache is empty, tb find does not sit there trying to ingest the whole profile before answering. It scans the real mailbox files directly, returns the hit, and leaves full cache hydration as an explicit operator step.
A mail tool that quietly answers from stale data is worse than one that fails, because you act on the answer. tb treats that as a correctness bug:
--syncthat cannot reach a display fails instead of falling through to the old cache- a sync that changed nothing says so, instead of assuming it worked
- empty results name the folders actually searched, so "no matches" is never confused with "wrong mailbox"
- headless send prints the Message-ID and can confirm delivery from the server with
--verify - a reply that cannot carry its threading headers is refused rather than sent unthreaded
tb doctortells you whichtbbinary a shell will actually run, and warns when copies disagree
tb q "billing dispute"One command, no decision tree. It searches every account and folder (Junk and Trash included, ranked below real mail), refreshes the cache if stale, ranks by relevance, and widens the match automatically when nothing hits — telling you which strategy worked.
Piped output is JSON, and every result carries the exact command to read it:
{
"message_id": "<[email protected]>",
"subject": "Re: [Ticket 13421571] billing dispute ...",
"date": "2026-07-23T11:07:51Z",
"read": "tb read --message-id \"<[email protected]>\""
}Results carry their scope (profile, account, folders searched, cache age), so an empty result is never confused with searching the wrong place. --text forces human output; TB_JSON=1 forces JSON everywhere.
One call, not four:
tb q --today --important # what actually needs attention today
tb q "billing dispute" --thread --body # the whole conversation, with bodies
tb q "electricity bill" --since 2019-07 --till 2019-07 --body # a specific month, amounts included
tb q "invoice" --from [email protected] --attachments # by sender, with attachment names
tb reply "support ticket" --body-file reply.txt # dry run; add --send to deliver--since/--till take YYYY-MM-DD, YYYY-MM, today, yesterday, or an offset (7d, 24h, 3mo); --till is inclusive. --important ranks by list headers, automated senders, whether you are in To/Cc, thread membership and deadline words — and prints importance_why for every message so the ranking can be checked.
The mail ... subcommands remain the full interface, but the fast operator path is now:
tb list --limit 20 --raw
tb list INBOX --account [email protected] --limit 20 --raw
tb tail --account [email protected] --limit 30 --raw --ignore-folder junk,trash
tb head --account [email protected] --limit 10 --raw
tb read --message-id '<message-id-from-list>'
tb find --account [email protected] --since 2026-01-01 --raw "keyword"
tb mail move --account [email protected] --source-mailbox Junk --dest-mailbox INBOX --message-id '<message-id-from-list>'tb list with no folder reads the inbox(es). Name a folder when you want a specific one; --folder also accepts a half-remembered name, so --folder "acme sent" resolves to ImapMail/mail.acme-1.example/Sent Items.
These are shorthands for:
tb list ...->tb mail recent ...tb tail ...->tb mail unified ...tb head ...->tb mail unified --oldest ...tb read ...->tb mail show ...tb find ...->tb mail search ...tb search ...still works as the older search shorthand
If you are chasing a fresh reply, delivery-status notice, or account-recovery message, do not begin with a narrow keyword search unless you already know the exact sender or subject.
That mistake is how people miss important human replies. Automated account mail often starts from one address, then a maintainer or support person answers from another address with a different subject line.
Use this order instead:
tb doctor
tb mail fetch --profile default --sync
tb tail --profile default --account [email protected] --limit 30 --raw --ignore-folder junk,trash
tb list "Junk Mail" --profile default --account [email protected] --limit 20 --raw
tb read --profile default --message-id '<message-id-from-recent>'
tb find --profile default --account [email protected] --since 2026-01-01 --raw "keyword you learned from the fresh mail"That sequence is deliberate:
fetch --syncmakes the local profile freshtail ... --rawgives you a unified inbox view across accounts without forcing you to guess which inbox caught the reply--ignore-accountand--ignore-folderlet you suppress noisy accounts or folders while triagingshow --message-idinspects the exact mail you just foundsearchcomes after you know what you are really hunting
--sync needs a display. tb finds one in this order:
- the GUI session of the current shell
- the session of an already-running Betterbird/Thunderbird — this is what makes
--syncwork over plain ssh, and it is the only option that works for a Flatpak build - a temporary
Xvfbdisplay
If none is available, --sync fails rather than quietly answering from the stale cache. tb doctor reports which path applies before you start:
tb doctor | grep 'Sync display path'You can cap sync duration with TB_SYNC_TIMEOUT=30s (default 90s), and the standalone tb mail sync --profile default --timeout 30s command runs that refresh without touching the cache backend.
If you skip fetch on a profile that has never been indexed, tb find now uses a direct mailbox scan as the safety net. That is for getting the answer now. Use tb mail fetch or tb mail search --refresh when you want repeated searches to stay fast.
When you need to prove what a receiving provider thinks about a message, use authcheck instead of stitching together compose, IMAP polling, and header scraping by hand:
tb mail authcheck \
--profile default \
--from [email protected] \
--to [email protected] \
--read-as [email protected] \
--wait 5mWhat it does:
- sends a real message through the sender identity already configured in Thunderbird or Betterbird
- polls the receiving account over IMAP using the credentials or OAuth token already stored in the same profile
- prints the delivered message's authentication headers such as
Authentication-Results,Received-SPF, andDKIM-Signature - falls back to scanning recent mailbox headers directly when a provider's IMAP header search misses the delivered message, so placement is still provable on flaky servers such as Outlook.com
If the reader account is Gmail, Yahoo, or Outlook, tb chooses sensible mailbox defaults automatically. Override them with --mailboxes when a provider files the message somewhere unusual.
When a provider accepts mail but files it in Junk or Spam, use tb mail move against the receiving account instead of relying on ad hoc GUI dragging:
tb mail move \
--profile default \
--account [email protected] \
--source-mailbox Junk \
--dest-mailbox INBOX \
--message-id '<[email protected]>'This performs a real IMAP move on the remote mailbox and gives you a repeatable inbox-training primitive for providers such as Outlook.
Thunderbird and Betterbird keep the mail. tb keeps the operator cache.
Default cache backend:
- SQLite
- location:
~/.local/state/thunderbird-cli/...unless overridden - good for local single-user operation
- zero database service to install
Optional cache backend:
- PostgreSQL
- enable only when you explicitly want it
- useful when you already have PostgreSQL and want that operational model
Future direction:
- a Panorama-aware importer or adapter can be added later
tbdoes not depend on Thunderbird's in-progress internal database work today
Choose PostgreSQL explicitly:
export TB_STORE=postgres
export TB_PG_DSN='postgres://user:pass@localhost/tb_cli?sslmode=disable'Override the SQLite path explicitly:
export TB_SQLITE_PATH="$HOME/.local/state/thunderbird-cli/custom/index-v1.db"Profile data is treated as read-only.
Normal writes go only to:
- the configured cache backend
- temporary isolated send-profile clones when GUI fallback send is required
- the optional legacy
.tb-index.jsonfile only if you explicitly runtb mail index
tb does not rewrite your live mbox files, .msf files, Thunderbird SQLite files, or prefs.js during normal search work.
Direct headless send currently supports:
- Google / Gmail
- Microsoft / Outlook / Hotmail / Office 365
- Yahoo
- standard SMTP/IMAP accounts that store encrypted username/password credentials in the Thunderbird or Betterbird profile
For OAuth-backed providers, tb mail compose --send --open=false does this:
- resolves the matching identity from
prefs.js - decrypts the stored refresh token from the profile
- refreshes an access token
- submits the message over SMTP with
XOAUTH2 - appends the exact RFC822 message to the real Sent folder over IMAP
- you can verify that sent copy online with
tb mail sentcheck --from <account> --subject "..."
For stored-password accounts, tb does the same operator job without opening Betterbird:
- resolves the matching identity from
prefs.js - decrypts the stored SMTP and IMAP credentials from the profile with NSS
- submits the message over SMTP using the server's advertised password auth mechanism
- appends the exact RFC822 message to the real Sent folder over IMAP
When direct send is not available on a machine or in a build, tb falls back to the isolated Betterbird/Thunderbird automation path.
Inspect what the current binary can actually do:
tb features
tb doctorLinux:
- release builds are intended to be the best-supported path
- direct secret-backed send depends on NSS runtime libraries being available
tb doctortells you whether the current binary and machine can do direct send
macOS and Windows:
- search, profile discovery, reading, and cache operations are the primary focus
- portable release builds may not include direct secret-backed send support yet
- use
tb featuresandtb doctorinstead of guessing
Windows:
- release archives are published
tb updateis not wired for Windows yet- download a fresh release archive when updating there
- an existing Thunderbird or Betterbird profile
- Linux or macOS for the
install.shfast path - Go 1.25+ if you are building from source
- NSS runtime libraries on Linux when you want direct secret-backed send
List available profiles:
tb mail profilesHydrate the default cache from a profile:
tb mail fetch --profile default --syncSearch across the cached corpus:
tb search "invoice" --profile default --limit 20
tb search "[email protected]" --profile default --since 2026-01-01 --limit 20Read the actual matching message:
tb mail show --profile default --folder INBOX --query "invoice" --limit 1 --threadtb mail fetch --profile default --sync
tb search "payment received" --profile default --limit 50
tb search "release train" --profile default --account [email protected] --since 2026-01-01 --limit 50tb search --profile default --refresh --raw "Mail delivery failed"tb mail fetch --profile default --sync --prune --full
tb search "audit trail" --profile default --limit 100tb mail compose \
--profile default \
--from [email protected] \
--to [email protected] \
--subject "Status update" \
--body "Hello" \
--opentb mail compose \
--profile default \
--from [email protected] \
--to [email protected] \
--cc [email protected] \
--subject "Status update" \
--body "Hello" \
--send --open=falseA successful send reports what it did, so you never have to guess whether it fired:
sent: [email protected] -> [email protected], [email protected]
message-id: <[email protected]>
transport: smtp+password via smtp.example.com:465
sent copy: appended to Sent Items
Support desks key off In-Reply-To/References. A reply without them usually opens a new ticket instead of appending to the open one.
tb mail compose \
--profile default \
--from [email protected] \
--to [email protected] \
--subject "Re: Ticket 13421571" \
--body-file reply.txt \
--in-reply-to '<[email protected]>' \
--send --open=false --verify 60s--body-filetakes a path, or-to read stdin. Use it for anything long enough that shell quoting over ssh becomes a hazard.--verify <duration>polls the Sent mailbox on the server for the Message-ID before returning. A stale local folder is not evidence of a failed send — do not re-send off a local-cache read.- confirm the headers landed with
tb mail sentcheck --from [email protected] --message-id '<id-from-the-send>'.
Threading headers require a direct-send identity. The isolated-profile fallback drives the GUI composer and cannot set them, so tb refuses that combination rather than sending an unthreaded reply.
tb doctor
tb features
tb versionInspect the environment:
tb doctor
tb features
tb versionFind profiles:
tb mail profilesList folders:
tb mail folders --profile defaultHydrate or refresh the cache:
tb mail fetch --profile default --sync
tb mail fetch --profile default --account [email protected] --folder INBOX
tb mail fetch --profile default --sync --prune --fullSearch:
tb search "security review" --profile default --limit 25
tb search "security review" --profile default --account [email protected] --since 2026-01-01 --till 2026-03-31 --limit 25
tb search --profile default --refresh --raw "support escalation"Read full messages:
tb mail show --profile default --folder INBOX --query "security review" --limit 1
tb mail show --profile default --folder INBOX --query "security review" --limit 1 --threadSend:
tb mail compose --profile default --to [email protected] --subject "Ping" --body "Hello"
tb mail compose --profile default --from [email protected] --to [email protected] --subject "Ping" --body "Hello" --send --open=falseMost users need none of these on day one.
Profile discovery order:
THUNDERBIRD_HOME~/.var/app/eu.betterbird.Betterbird/.thunderbird~/.var/app/org.mozilla.Thunderbird/.thunderbird~/.thunderbird
Relevant environment variables:
TB_STORE:sqlite(default) orpostgresTB_SQLITE_PATH: explicit SQLite cache file pathTB_PG_DSN: PostgreSQL DSN whenTB_STORE=postgresTHUNDERBIRD_HOME: explicit profile root overrideTHUNDERBIRD_BIN: explicit Thunderbird/Betterbird binary overrideTHUNDERBIRD_FLATPAK_ID: explicit Flatpak app id overrideTB_SYNC_TIMEOUT: how long--synclets the mail client fetch (default90s)TB_AUTO_REFRESH_MINUTES: refresh the cache when it is staler than this (default10,0disables)TB_VERBOSE:1to list every skipped folder instead of the one-line summary
tb is designed to be driven by agents because it is deterministic, local, and inspectable.
Codex example:
Use tb on this machine to inspect Thunderbird mail. Run `tb doctor`, refresh profile `default`, then search for "[email protected]" since 2026-01-01 and return the top 10 results in raw mode.
Claude Code example:
Use thunderbird-cli from the local checkout. Verify capabilities with `tb features`, hydrate the cache for profile `default`, then show the first full message in INBOX matching "Mail delivery failed".
Practical rule for agents:
- start with
tb doctor - use
tb mail fetch --syncbefore time-sensitive hunts - inspect
tb mail recent INBOX --rawandtb mail recent "Junk Mail" --rawbefore trusting a guessed keyword - when
recentreveals the right mail, open it withtb mail show --message-id '<...>' - prefer
tb search --rawwhen another tool needs stable parseable output - verify automated send through Sent/INBOX/Junk evidence, not SMTP success alone
Anonymized incident pattern worth following:
- an automated support thread started from one service address
- the real answer later arrived from a human maintainer address under a different subject
- keyword search on the original service address missed it
- the correct workflow was
fetch --sync,recent INBOX,recent Junk Mail, thenshow --message-id
For machines where mail search should stay warm, a user timer is enough.
Service:
[Unit]
Description=Refresh thunderbird-cli cache
[Service]
Type=oneshot
ExecStart=%h/.local/bin/tb mail fetch --profile default --syncTimer:
[Unit]
Description=Refresh thunderbird-cli cache every 30 minutes
[Timer]
OnBootSec=2m
OnUnitActiveSec=30m
Unit=thunderbird-cli-refresh.service
[Install]
WantedBy=timers.targetInstall the runtime libraries for your distribution and rerun tb doctor.
On Debian and Ubuntu:
sudo apt install libnss3 libnspr4That means this binary was built without NSS-backed secret decryption for direct send.
Use one of these paths:
- use the fallback send path
- build from source on Linux with cgo enabled and NSS development/runtime libraries available
- inspect the current build with
tb features
Try the normal progression:
tb mail fetch --profile default --sync
tb search --profile default --refresh --raw "your query"Expected over plain ssh when nothing is running to join. tb fails here on purpose: the alternative is answering from a stale cache while looking like it fetched fresh mail.
Check what path is available before you start a time-sensitive hunt:
tb doctor | grep 'Sync display path'Then pick one:
- open Betterbird/Thunderbird on the desktop session —
tbwill join it automatically - point
THUNDERBIRD_BINat a native binary so-headlessis usable - install
Xvfbfor a temporary virtual display - drop
--syncand search the existing cache deliberately
On 3.1.0 and later it prints the Message-ID, transport, and Sent-copy status. If you are unsure about an older send, do not re-send off a local folder read — the local mbox cache lags and has caused duplicate mail. Ask the server instead:
tb mail sentcheck --from [email protected] --subject "the exact subject" --wait 30sYou still can. It is just no longer required for first-run usability.
export TB_STORE=postgres
export TB_PG_DSN='postgres://user:pass@localhost/tb_cli?sslmode=disable'
tb mail fetch --profile default --sync
tb search "invoice" --profile defaultRun the local smoke suite:
./tests/run.shLocal source builds are also how you should test capability differences that depend on the host machine, especially direct send and runtime library resolution.
- code: Apache-2.0
- Markdown documentation: CC BY-SA 4.0
See LICENSE and LICENSE-CC-BY-SA-4.0.