The mailcoded manual
This is the handbook for people who read their own mail with mailcoded, from a terminal. It covers installing it, adding an account, the full-screen client, every command-line verb, sending, and what to do when something goes wrong.
It is deliberately not the design documentation. If you want to know why the daemon speaks JSON-RPC or how the sync planner works, start at docs/ARCHITECTURE.md. If you are giving an AI agent access to your mail, docs/agents.md is the document for you; chapter 11 here only points at it.
Everything in this manual was checked against mailcoded 0.1.0, protocol 1, on 2026-09-06, by
running the commands. Where the manual and mailcoded help <verb> disagree, the program is right and
the manual has a bug — please report it.
The short version
scripts/install.sh # build, and put the four commands on PATH
mailcoded setup # add an account; nothing is saved until a real login succeeds
mailcoded tui # read it. Press ? for the keys.
Contents
- Installing — build from source, where the binaries go, checking it works
- Your first account —
setup, provider quirks, app passwords, Microsoft sign-in, scripted setup - Where your mail lives — the data directory, what is in it, secrets, backups, editors
- The terminal client —
mailcoded tui: the screen, keys, mouse, reading, triage, compose - Reading and searching from the command line —
search,read,thread,attachments,folders - Triage — Tags and Flags,
tag,move,archive, and why there is no delete - Sending — drafts, the two-phase send, the outbox, and who the send gate applies to
- Accounts —
account list,test,reauth,add,forget - Sync and the daemon —
sync, live updates, one daemon per store, running the daemon yourself - Importing mail and raw SQL —
import-eml,query --sql, reading the audit trail - Agents — the two-minute version, and where the real document is
- The safety model, in plain terms — what mailcoded will never do, and why you may see �
- Troubleshooting — the messages you might see, what they mean, what to do
- Reference — environment variables, exit codes, every key, every verb, every file
Conventions
mailcodedis the command-line tool.mailcoded-tui,mailcoded-daemonandmailcoded-mcpare the other three binaries the installer puts beside it; you rarely run them directly.<id>is a local message id — the number in the first column ofsearchoutput. It is stable within one store and means nothing outside it.- The vocabulary is deliberate and the manual sticks to it: an account has folders (never
“mailboxes”); a message carries server Flags (
unread,flagged,replied,draft) and local Tags (never “labels”). --jsonoutput is a contract, versioned byschema_version. The human-readable output is not, and may change between releases.
1. Installing
There are no release binaries yet. You build from source, and the installer puts four commands on your PATH. The first time takes a few minutes, because Native AOT compilation is slow; after that it is a rebuild.
What you need
- The .NET 10 SDK. On NixOS, or under WSL with Nix, run
nix developin the checkout first. git.- For the full-screen client, a terminal that understands ANSI escape sequences. Every Linux and macOS
terminal does. On Windows use Windows Terminal; the legacy console is refused with
This console cannot render ANSI. Try Windows Terminal.
Install
git clone https://github.com/MailCoded/mailcoded
cd mailcoded
scripts/install.sh
That publishes and installs:
| Command | What it is |
|---|---|
mailcoded | the command-line tool — every verb in chapters 5 to 10 |
mailcoded-tui | the full-screen terminal client (chapter 4) |
mailcoded-daemon | the engine the TUI talks to; JSON-RPC over stdio (chapter 9) |
mailcoded-mcp | the MCP adapter for agent hosts that cannot run a shell (chapter 11) |
Options:
--prefix DIR | install root; default $HOME/.local, or $PREFIX |
--rid RID | build for another runtime identifier, such as osx-arm64 or win-x64 |
--no-aot | install the framework-dependent build instead: much faster to install, but needs the .NET runtime present to run |
--uninstall | remove the symlinks and the payload directory |
Make sure $PREFIX/bin — normally ~/.local/bin — is on your PATH.
Where it goes, and why
The binaries land in $PREFIX/libexec/mailcoded/, and only symlinks go into $PREFIX/bin. Two reasons
not to “tidy” this:
- The AOT binaries load
libe_sqlite3.sofrom their own directory and will not start without it. The executable and the library have to stay together; a symlink to the executable is fine. $PREFIX/share/mailcodedis deliberately not used, because on Linux that path is$XDG_DATA_HOME/mailcoded— your mail store. The installer refuses to write over, or remove, any directory that looks like one.
mailcoded tui looks for mailcoded-tui and mailcoded-daemon beside its own binary first, then on
PATH, so an installed set stays together even if an older copy is lying around elsewhere.
Check it
mailcoded version
mailcoded-tui --check
The first prints the version and protocol numbers and opens nothing:
mailcoded 0.1.0
protocol 1, output schema 1
.NET 10.0.10 on linux-x64
The second starts a daemon, connects to it over the same wire the TUI uses, reports, and exits:
daemon 0.1.0
methods 20
maxSearch 200
accounts 0
ok
accounts 0 is right before you have added one. If --check fails instead, chapter 13 lists the
messages.
Sizes
Measured on 2026-09-06 on one linux-x64 machine, Native AOT: mailcoded-tui 5.9 MB, mailcoded
16 MB, mailcoded-daemon 17 MB, each beside a shared libe_sqlite3.so of about 1.5 MB. The TUI is
small because it is built against the wire protocol alone and carries no IMAP, MIME or SQLite code.
None of these is “a single binary”; distribute each with its library.
Building without installing
scripts/build.sh # or: dotnet build Mailcoded.slnx -m:1
scripts/test.sh # the unit suite
The binaries are then under src/<Project>/bin/Debug/net10.0/, in four separate directories, so
mailcoded tui cannot find its siblings from there. Run the client directly instead and tell it where
the daemon is:
MAILCODED_DAEMON=src/Mailcoded.Daemon/bin/Debug/net10.0/mailcoded-daemon \
src/Mailcoded.Tui/bin/Debug/net10.0/mailcoded-tui
Uninstalling
scripts/install.sh --uninstall
removes the commands. It never touches your mail store, which lives somewhere else entirely (chapter 3).
2. Your first account
mailcoded setup
setup is interactive and needs a terminal. It works out your provider’s IMAP and SMTP settings from
your address, tells you which kind of credential that provider actually accepts, proves the settings
work with a real login, and only then writes anything down. A failed password attempt leaves no
account and no stored credential behind.
What it asks
- Your email address. Skip the question with
--email you@example.com. - Whether the detected settings look right. Edit them if not.
--imap-host,--imap-port,--smtp-hostand--smtp-portpre-fill them. - Your password, or app password. Typed at a prompt, never echoed, and never a command-line argument, so it cannot land in your shell history. It goes to the OS keyring (chapter 3) — never to the database, the config, or a log line.
- Whether to sync now. The first sync of a large mailbox takes a while. Say no and run
mailcoded sync --account <id>later if you prefer.
--display-name <label> gives the account a label. --json also prints a machine-readable summary on
stdout; the prompts go to stderr, so stdout stays clean.
Providers it knows
Settings are built in for Gmail, Outlook.com, Yahoo, iCloud, Fastmail, AOL, Zoho, GMX, WEB.DE, Yandex,
Mail.ru, QQ, Foxmail, NetEase (163/126) and Proton Bridge. Any other domain is guessed as
imap.<your-domain> and smtp.<your-domain>, which you correct at step 2.
App passwords
Many providers reject your ordinary account password over IMAP and require an app password that
you generate in their security settings — Gmail, Yahoo, iCloud, Fastmail, AOL, Zoho and QQ among
them. setup says so, with the link, before it asks you to type anything. Gmail additionally requires
2-Step Verification to be on before it will issue one, and WEB.DE needs IMAP enabled in its web
interface first.
If the login fails, setup prints a one-line diagnosis — The server refused that credential., or
Could not reach <host>:<port>. — with hints, saves nothing, and tells you to run it again.
Microsoft accounts
For outlook.com, hotmail.com, hotmail.co.uk, live.com, live.co.uk, msn.com and office365.com addresses,
setup offers a choice:
How would you like to sign in?
1. Sign in with Microsoft (opens a browser, recommended)
2. Use a password or app password
3. Cancel
Microsoft has been withdrawing basic authentication, so a plain password is usually refused; an app
password may still work if your account has them enabled. Signing in with Microsoft is the durable
option. It is a device-code sign-in: setup prints a web address and a short code —
Open: https://microsoft.com/devicelogin
Code: ABCD-EFGH
Waiting for you to finish in the browser...
— you open the page on any device, enter the code, and approve. The grant is stored in the keyring, and
from then on mailcoded refreshes its own access tokens. If Microsoft does not hand back a code within
45 seconds you are told Microsoft did not return a sign-in code within 45s. Check the network, then try again.
Two things to know:
- The consent screen will not say “mailcoded”. mailcoded has no OAuth client registration of its
own yet, so it borrows a public one — and Microsoft may refuse or revoke that at any time. To use
your own, register a public-client app with device-code flow enabled and pass
--client-id <guid>, or setMAILCODED_OAUTH_CLIENT_ID.--tenant(orMAILCODED_OAUTH_TENANT) selectscommon— the default, personal and work accounts — orconsumers,organizations, or one tenant’s GUID. - It asks for two permissions: IMAP access as you, and sending mail over SMTP. Nothing else.
- The grant is stored as soon as the browser step completes, before the mailbox login is checked.
If the mailbox then refuses the token, no account is created, but the grant stays in the keyring; a
later successful
setupfor the same address reuses it. - A Microsoft 365 mailbox on your own domain is not recognised as Microsoft. It is treated as an
unknown provider — guessed hosts, password only — and the sign-in choice is not offered. Point it at
outlook.office365.comwith--imap-host; it will work only if your tenant still allows an app password.
Afterwards
mailcoded account list # what was added
mailcoded account test # prove the settings and credential work; changes nothing
mailcoded folders # the folders it found, with counts
mailcoded tui # read it
For scripts: account add
setup refuses to run without a terminal. The non-interactive form takes every setting as an option
and reads the credential from stdin:
printf '%s' "$IMAP_PASSWORD" | mailcoded account add --json \
--email me@example.com --imap-host imap.example.com \
--smtp-host smtp.example.com --password-stdin
| Option | |
|---|---|
--email <addr> | required |
--display-name <text> | |
--imap-host <host> | required |
--imap-port <n> | default 993 |
--imap-security <mode> | none, sslOnConnect, startTls, startTlsWhenAvailable |
--imap-user <name> | defaults to the email address |
--smtp-host <host> | required before this account can send |
--smtp-port <n> | default 587 |
--smtp-security <mode> | default startTls |
--smtp-user <name> | |
--secret-ref <handle> | reuse an existing secret handle instead of deriving one |
--password-stdin | read the credential from stdin |
--no-password | register without storing a credential |
An account registered with --no-password cannot sync until a credential is added with
account reauth (chapter 8); the daemon says so once and then leaves it alone, rather than retrying a
login that cannot succeed. Run mailcoded account test after any account add to prove the settings.
More than one account
Run setup again. Every verb that needs to know which account you mean takes --account, by id or by
address — except search, whose --account is the numeric id only. With a single account it is
implied. The terminal client shows all of them in one sidebar.
3. Where your mail lives
mailcoded keeps everything in one data directory:
| Linux | $XDG_DATA_HOME/mailcoded, normally ~/.local/share/mailcoded |
| macOS | ~/Library/Application Support/mailcoded |
| Windows | %LOCALAPPDATA%\mailcoded |
Three overrides, in increasing order of specificity: the MAILCODED_DATA_DIR environment variable,
--data-dir <path>, and --db <path>, which names store.db itself. mailcoded tui passes
--data-dir and --db through to the client and its daemon.
What is in it
store.db, store.db-wal, store.db-shm | the SQLite database: accounts, folders, every envelope, the search index, Tags, drafts and the outbox, and the append-only sync_log audit trail. It runs in WAL mode, so the -wal and -shm files are normal. |
blobs/ | raw message bytes, fetched on demand |
secrets.enc, secrets.key | the encrypted-file credential vault and, on Linux and macOS, the machine key that unlocks it. They appear only once a credential has actually been written to the file backend; with a working keyring their absence is normal. On Windows there is no secrets.key — the vault key is DPAPI-wrapped inside secrets.enc |
daemon.lock | the OS lock that makes one daemon the owner of this store’s live connections (chapter 9) |
daemon.owner | a diagnostic stamp — pid, start time, version — of the current or most recent owner; nothing reads it to make a decision |
Secrets
Credentials never touch the database, the account configuration, a log line, an RPC response or an error message. They go to a secret store, which is a chain: the OS keyring first, an encrypted file as the fallback.
| Platform | Keyring | Fallback |
|---|---|---|
| Linux | libsecret — the Secret Service API that GNOME Keyring and KWallet provide | encrypted file |
| macOS | Keychain | encrypted file |
| Windows | Credential Manager | DPAPI-protected file |
| Headless, containers | — | encrypted file only |
mailcoded health tells you which chain is active — secrets: chain(libsecret,file), say.
MAILCODED_SECRET_BACKEND forces one: auto (the default), file, libsecret, keychain or
wincred.
The file vault is secrets.enc. On Linux and macOS it is unlocked by the machine key in secrets.key
beside it; copy the two files together or not at all. On Windows the key is wrapped with DPAPI for the
current user and kept inside secrets.enc itself, so the file opens only for the Windows account that
created it, on that machine. On any platform, a passphrase from MAILCODED_SECRET_KEY — or from a file
named by MAILCODED_SECRET_KEY_FILE — takes the place of the machine key, if it is set when the vault
is first created.
A Microsoft sign-in stores its grant in the same secret store, under the account’s handle with
:oauth-cache appended. account forget removes both.
Backups and moving
Mail is re-syncable: the server is the source of truth, and a fresh sync is always a valid recovery. What exists only in your store is:
- your local Tags — anything other than
unread,flagged,repliedanddraft; - drafts and the outbox;
- the
sync_logaudit trail.
To back it up, copy the whole directory while nothing is running: no TUI open, daemon.lock free. To
move it, copy it and set MAILCODED_DATA_DIR. Credentials in the OS keyring do not travel with the
directory, and on Windows neither does a DPAPI-protected secrets.enc; on a new machine — or as a
different Windows user — run mailcoded account reauth.
For experiments, use a throwaway store:
mailcoded --db /tmp/mail.db import-eml fixtures/eml
mailcoded --db /tmp/mail.db tui
Editors
Never open the data directory as an editor workspace, and exclude it from any indexer or file watcher: a large SQLite database and its WAL would otherwise be re-indexed on every write. For VS Code:
{
"files.watcherExclude": { "**/mailcoded/**": true, "**/*.db": true, "**/*.db-wal": true, "**/*.db-shm": true },
"search.exclude": { "**/mailcoded/**": true, "**/*.db": true, "**/*.db-wal": true, "**/*.db-shm": true }
}
Windows
- Enable long paths. The store root is kept short on purpose, but blob and export paths can still
exceed
MAX_PATH. SetHKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabledto1(a reboot applies it), or enable Enable Win32 long paths in Group Policy. - Consider a Defender exclusion for
%LOCALAPPDATA%\mailcoded. Real-time scanning of a busy SQLite file and its WAL costs sync throughput. It is your trade-off to make — mail content is what would be scanned:Add-MpPreference -ExclusionPath "$env:LOCALAPPDATA\mailcoded".
4. The terminal client
mailcoded tui
A full-screen client for the terminal: every account in one sidebar, a message list, a preview, a
reader, triage keys, and compose with the two-phase send. Press ? inside it for the key list, and
watch the bar above the status line — it always shows what works right now, and its entries are
clickable.
Unlike every other verb, tui does not run in the CLI’s process. It starts mailcoded-daemon and
talks to it over JSON-RPC, exactly as a third-party client would. That is deliberate: it is what makes
the TUI the worked example behind docs/rpc.md. It never asks the daemon for HTML — a
terminal has no sandbox — so what you read is the plaintext part of each message.
Starting it
mailcoded tui | the normal way; finds mailcoded-tui and mailcoded-daemon beside the CLI, then on PATH |
mailcoded tui --data-dir <dir>, --db <path> | another store |
mailcoded-tui [--store <path>] | the binary directly; MAILCODED_DAEMON=<path> picks the daemon executable |
mailcoded-tui --check | connect, print the daemon version, method count and account count, exit; no screen |
It needs a real terminal: with stdin or stdout redirected, mailcoded tui refuses rather than hanging.
Exit codes: 0 normally; 2 when the console cannot render ANSI (on Windows, use Windows Terminal);
3 when the daemon went away, or --check failed; 4 when the daemon refused the connection.
With no account configured it opens and says so: No account is configured. Run 'mailcoded setup' first. Accounts are added with the CLI (chapter 2); the TUI shows whatever exists.
The screen
mailcoded you@example.com (2 accounts) header
- you@example.com 12/340 |*!@ sender subject date | From: ...
Inbox 12/300 | * alice Quarterly numbers 09:14 | Subject: ...
Drafts 2 | bob Re: invoice Sep 04 | ----------
Sent 38 | !@ accounts Invoice 4471 Sep 02 | body ...
+ other@example.org 3/1200 | |
j/k move enter read p preview c compose / search ... key bar
300 in Inbox n for more status line
- Header — the account that owns the selected folder, and how many accounts there are.
- Sidebar — every account and, under each, its folders: the special-use ones first, in the
familiar order (Inbox, Drafts, Sent, Trash or Deleted, Junk, Archive, All), then the rest
alphabetically, nested on
/.-marks an expanded row,+a collapsed one. Counts areunread/total; a folder with nothing unread shows only its total, and an account row totals its folders. Roles come from the server’s special-use flags, so the order holds for a Trash folder called “Papierkorb”. - Message list — three marker cells (
*unread,!flagged,@has attachments), the sender, the subject, and a date that isHH:mmfor today andMon ddotherwise. Unread rows are bright and read rows dim. It loads 100 at a time; the status line saysn for morewhen there are more. - Preview — on a terminal at least 100 columns wide, the right-hand pane shows the selected
message’s From, Subject and Date, its attachments, and its body. It is on by default;
ptoggles it. A body that has not been downloaded is fetched from the server once you have rested on the row for about a third of a second, so scrolling quickly through a list does not fetch every row it passes. - Key bar — the bindings that apply now. It changes when you open a message, start a search, compose, or reach the confirmation screen. Click an entry to press it.
- Status line — what just happened, or a hint. Prompts (search, tags, discard) appear here too.
It opens on the Inbox of the first account.
Moving around
| Key | |
|---|---|
j / ↓, k / ↑ | next, previous |
g, G, Home, End | first, last |
space, PgDn, PgUp | a page |
ctrl-d, ctrl-u | half a page |
n | load the next 100 messages |
tab, h / ←, l / → | between the sidebar and the list |
h / l in the sidebar | collapse, expand — or jump to the parent, into the first child |
enter | open the folder; read the message |
ctrl-l | redraw |
q | back out of the reader; from the list, quit. ctrl-c quits from anywhere |
Escape undoes the innermost thing first: a running operation, then the open message, then a search.
Reading
enter on a message opens the reader and marks it read — a Flag change, pushed to the server. The
headers come first (Subject, From, To, Cc, Date, Tags, one Attach: line per attachment with its
index, name, type and size, and any parse warnings), then the body, wrapped at up to 100 columns.
| Key | |
|---|---|
j / k, space, ctrl-d / ctrl-u, g / G | scroll |
r, R | reply, reply to all |
u, f | toggle unread, flagged |
t | edit tags |
a, m | archive, move |
T | show the whole conversation |
s then a digit | save that attachment |
q or esc | back to the list |
A body that has not been downloaded is fetched when you open the message. If a message genuinely has
no plaintext part, the reader says (this message has no plaintext part); it does not fall back to
HTML.
Searching
/ opens a prompt on the status line. The query language is the one mailcoded search uses
(chapter 5): words, "phrases", from:, to:, subject:, tag:, is:unread, has:attachment,
before: and after: dates, - to negate. A search covers the whole account the selected folder
belongs to and comes back by relevance; esc clears it and returns you to the folder.
Triage
Every key here acts on the open message, or else on the highlighted row.
| Key | ||
|---|---|---|
u | toggle unread | Flags are pushed to the server in the same call |
f | toggle flagged | |
t | edit tags | a prompt, space-separated: triaged followup -inbox adds two and removes one; a leading + is optional |
a | archive | to the folder the server marks as Archive; if the account has none, you are told to use m |
m | move | the sidebar becomes a picker — j/k to the destination, enter to move, esc to cancel. Within the same account only |
T | thread | replaces the list with the conversation, oldest first; esc goes back |
r | sync this folder (from the list) | the status line reports +added ~updated -removed in N ms |
s then 0–9 | save an attachment | into ~/Downloads, under the safe name the daemon assigned; never overwrites — a second invoice.pdf is saved as invoice (2).pdf |
A | test this account’s connection | the result appears in the status line |
S | daemon status | version, uptime, memory; per account its connection, auth, whether it is being watched, outbox counts and last error |
o | outbox | queued, sending, sent and failed sends, with the SMTP reply where there is one |
A Tag name is lower-cased, at most 128 characters, printable ASCII, with no whitespace and none of
" ' ( ) * : ; , \ /.
Composing and sending
c starts a blank message. r and R in the reader start a reply with the recipients, a Re:
subject, the quoted original and the threading headers already filled in. The composer has To, Cc,
Bcc, Subject and a body.
| Key | |
|---|---|
tab, shift-tab | next, previous field |
enter | a new line in the body; the next field elsewhere |
arrows, home, end, backspace | edit |
ctrl-s | preview the send |
esc, ctrl-c | discard — it asks discard the draft? y/n: first |
Addresses are comma-separated. Attachments cannot be added in this release.
ctrl-s sends the draft to the daemon for a preview and opens the confirmation screen:
SEND THIS MESSAGE?
From: you@example.com
To: bob@example.org
Cc: team@example.org
Subject: Re: invoice
Size: 1204 bytes
Thanks - looking now.
...
This confirmation expires in 597s.
press Y to send, anything else to go back
Every recipient is listed, in a colour meant to make you read it. Only a capital Y sends. Any
other key returns you to the composer with the draft intact: Not sent. The draft is still here.
The preview comes with a one-time confirmation token. The TUI holds it in memory and never displays
it; it is valid for ten minutes and bound to exactly the bytes you previewed. If it expires, or the
send is refused, you are put back in the composer and told why — That confirmation is spent or expired. Preview again., or the server’s own message, with Run 'mailcoded account reauth'. added
when the problem is authentication. After a successful send the status line reports the result.
The TUI talks to the daemon as an editor-style client, not as an agent, so the agent-only send
controls — MAILCODED_SEND, the recipient allowlist, the hourly budget (chapter 7) — do not apply to
it. The confirmation screen is the control.
Live updates
At startup the TUI asks the daemon to watch every account’s folders. New mail arrives as
3 new in INBOX - r to refresh — the folder named by its server path — and a folder whose counts
changed is updated in the sidebar.
Watching uses IMAP IDLE, so the daemon keeps one connection open per watched folder.
Only one daemon can hold a store’s live connections at a time (chapter 9). If another mailcoded is
already watching this store — a second TUI window, say — this one still works but tells you, once:
Live updates belong to another mailcoded window; press r to refresh here.
An account that cannot sync — no stored credential, or a server that refuses — is reported in red on
the status line as sync: <reason>.
Slow operations
Anything that goes to the server runs in the background, and the status line says what it is doing:
opening, syncing Inbox, previewing. If it takes long, the line changes to Still syncing Inbox. Press esc to give up on it. — esc cancels it, and the screen stays usable throughout.
Mouse
On Linux and macOS the mouse works: click a folder or a message to select it, double-click a message
to open it, click an account row to fold or unfold it, scroll with the wheel (three rows a notch), and click any
entry in the key bar. Your terminal’s own text selection needs shift held down while mouse
reporting is on. The client turns the mouse on only when it could put the terminal into raw mode,
which it does through stty; on Windows it is keyboard-only.
Not in the TUI yet
Adding, re-authenticating and removing accounts, importing .eml files, and raw SQL are command-line
only for now (chapters 2, 8, 10). There is no HTML view, by design.
5. Reading and searching from the command line
Every verb here reads the local store and prints for a human; add --json and it prints a document
your scripts can rely on. Nothing in this chapter changes your mail, and only read and
attachments ever go to the server — to fetch a body or an attachment that has not been downloaded
yet, once.
search
mailcoded search '<query>' [--limit 50] [--cursor <c>] [--account <id>] [--folder <id>]
[--order relevance|date] [--no-snippet]
Full-text over subject, sender, recipients and body, plus structured predicates. Local only.
| Syntax | Matches |
|---|---|
invoice | the word, anywhere; diacritics fold, so café also finds cafe |
"two words" | the phrase |
inv* | words beginning inv |
from:acme, to:bob@example.com, cc:team | the address or name contains |
subject:invoice | the subject contains |
tag:triaged | carries the local Tag |
folder:INBOX, in:INBOX | in that folder |
is:unread, is:read, is:flagged, is:unflagged, is:draft, is:replied | server Flags; seen, starred and answered are accepted synonyms |
has:attachment | carries an attachment |
before:2026-01-31, after:2026-01-01, since:2026-01-01 | strictly earlier; at or after. ISO dates |
-term, -tag:spam, not term | negation |
Terms are combined with AND. OR is not supported; it is reported in errors and skipped. A query is
capped at 64 terms and 4096 characters. A malformed query never fails: the parser reports what it could
not read and searches with the rest, so look at errors in the JSON when a result seems thin.
Chinese, Japanese and Korean text is indexed by trigram, with a slower exact match for one- and two-character terms.
$ mailcoded search 'invoice'
14 2025-01-19T08:00:00.000Z [unread] accounts@example.com
Invoice 4471
Invoice attached.
1 hit(s), truncated=false
The first column is the message id the other verbs take. Latin-script text queries come back by
relevance; Chinese, Japanese and Korean text, and queries with no text at all, always come back newest
first. --order date asks for newest first explicitly.
Paging. --limit is 1 to 200, default 50. When there is more, the human output ends with the
exact command to continue —
3 hit(s), truncated=true
next: mailcoded search '<same query>' --cursor k1737374400000.23
— and the JSON carries truncated and next_cursor. Pass the cursor back verbatim. truncated: true
with a null cursor means the rest lies beyond what any cursor reaches: narrow the query, or use
--order date. Follow the cursor rather than raising --limit.
read
mailcoded read <id> [--no-fetch] [--max-chars 20000] [--skip-chars 0]
One message, as plaintext. There is no HTML mode and no flag that adds one.
$ mailcoded read 14
id: 14
date: 2025-01-19T08:00:00.000Z
from: accounts@example.com
to: bob@example.org
subject: Invoice 4471
flags: unread
tags: unread
attachments: yes (bytes are not exposed to the CLI)
Invoice attached.
When the body has not been downloaded yet, read connects to the account’s IMAP server, fetches it
once, stores it and indexes it. --no-fetch stays offline and prints whatever is stored. For a long
message, --max-chars (1 to 1000000, default 20000) and --skip-chars page through the body.
Anything in a message that could drive your terminal — escape sequences, invisible and direction-changing characters — is neutralised before it is printed (chapter 12).
thread
mailcoded thread <id|threadKey> [--limit 200]
Every message in one conversation, oldest first. A number is taken as a local message id and its thread
is resolved for you; anything else is treated as a thread key. --limit is 1 to 1000.
attachments
mailcoded attachments <id> [--no-fetch]
mailcoded attachments <id> --save <index> [--out <dir>] [--overwrite]
The first form lists them:
0 invoice.pdf application/pdf 84 KB
Save one with: mailcoded attachments 14 --save <index> --out <dir>
The second writes one to disk — into --out, or the current directory — under the flattened,
path-safe filename the parser assigned, never a path taken from the message. It refuses to overwrite
an existing file unless you pass --overwrite. read never hands you attachment bytes; this is the
only verb that does, and it writes them to a file rather than printing them.
folders
mailcoded folders [--account <id>]
Each folder with its id, the locally stored unread and total counts, and its path; --json adds the
role and the last successful sync.
1 37 unread 37 total INBOX
stats and health
mailcoded stats
mailcoded health
stats is counters: schema version, database and blob sizes, process memory, the outbox, how many
sends are left in the current hour, and per-folder counts. Nothing in it identifies a message or a
person.
health is state: the store path and schema, which secret backend is active, whether the send and SQL
gates are open, each account’s connection and authentication state, and a store-wide count of sends
stuck mid-dispatch.
status: ok
store: v6 at /home/you/.local/share/mailcoded/store.db
secrets: chain(libsecret,file)
gates: send=off sql=off
health exits 0 even when something is degraded; with --json, branch on its status field, which is
ok or degraded. (ok is true in every successful document and says nothing about health.)
Output and exit codes
Every --json document starts with schema_version and ok. Errors go to stderr — with --json,
as a JSON object whose error.code is the RPC numeric code — and the exit code says what kind went
wrong: 2 you sent bad arguments, 3 nothing by that id, 7 the credential failed, 8 the network did, and
so on. The full table is in chapter 14.
6. Triage
Tags and Flags
mailcoded keeps two kinds of marker on a message, and the words are deliberate.
- Flags belong to the server:
unread,flagged,replied,draft. Every mail client you own sees them. The server wins: whatever it says on the next sync is what you get. - Tags are local to this machine:
triaged,followup,project-x, anything you like. Local wins: sync never touches them.
The four Flag names are also Tags, so one command handles both. Set unread and mailcoded pushes the
IMAP flag; set triaged and it stays on your machine. Nothing is ever called a “label”.
A Tag name is at most 128 characters of printable ASCII, with no whitespace and none of
" ' ( ) * : ; , \ /, and is lower-cased.
tag
mailcoded tag <id> +tag -tag [...] [--local]
+ adds, - removes; the sign is required.
$ mailcoded tag 37 +triaged --local
37: triaged,unread
flags: unread pushed_to_server=false
the server push was deferred (no provider connection); the local tags are saved.
The first line is the message’s complete Tag set afterwards; the second says which of them are Flags
and whether they reached the server. --local applies the change locally and opens no connection, which
is why the push above was deferred. Use it for your own Tags, which never go to the server anyway.
Do not use it for the four Flag names: a local-only change to unread or flagged is reverted by
the next sync, because the server wins.
Without --local, mailcoded connects and pushes the Flags in the same call. If the server cannot be
reached, the local change is still saved and the same deferred line tells you so.
move
mailcoded move <id> --folder <name|id>
Moves the message to another folder of the same account, on the server. --folder takes a folder
id, a full path (Projects/Acme) or a leaf name (Acme), matched case-insensitively; an unknown name
is answered with the list of folders the account has.
The command line is treated as an agent surface (chapter 11), and moving mail is outside the default
agent posture, so on the CLI move and archive need MAILCODED_ALLOW_MOVE=1 in the environment
in every case — a flag alone is something an agent could pass; the environment variable is a
change a human had to make deliberately. With it set, a terminal still asks first —
Move message 37 to Archive? [Y/n]:, and Enter accepts — and an unattended run passes --yes instead
of answering.
Without the variable, a script is refused before anything is looked up:
mailcoded: invalid_params (-32602): Moving mail is outside the default agent posture. Re-run from a
terminal to confirm, or set MAILCODED_ALLOW_MOVE=1 and pass --yes for an unattended run.
and an interactive run is stopped by the core gate after you have answered the prompt, with exit 4:
Moving mail is not part of the agent surface. The first message’s suggestion to re-run from a
terminal is not enough on its own; set the variable. The TUI is not an agent surface and needs none of
this — m just works.
move does not exist on the MCP surface at all.
archive
mailcoded archive <id>
A move to the folder the server marks with the Archive special-use role, confirmed the same way. If
the account has no such folder you are told to use move --folder instead.
There is no delete
No verb removes mail. There is no delete, expunge, trash or purge — not in the CLI, the TUI,
the MCP server or the protocol — and no flag that enables one. This is deliberate: a tool that cannot
delete cannot be talked into deleting, and re-syncing from the server is always a safe way back.
What you can do is move a message to your provider’s Trash or Deleted Items folder with move, and
let the server apply its own retention. mailcoded moves it; the server removes it, on its own
schedule.
7. Sending
Sending is two-phase everywhere — the TUI, the CLI and the MCP server — because a mail tool that can be scripted can be scripted into sending the wrong thing:
- Draft. Build the message and queue it. Drafting is always allowed, and never sends.
- Preview. See exactly who would receive it, and receive a single-use confirm token bound to those exact bytes, valid for ten minutes.
- Send. Hand the token back. A spent, expired or mismatched token is refused.
In the TUI that is ctrl-s, the confirmation screen, and Y (chapter 4). From the command line it is
three verbs.
Who the gate applies to
This surprises people, so it comes first. The command line is deliberately an agent surface — the
same thing an AI agent would drive — and the agent controls apply to it whoever is typing. From the
CLI, send-draft needs, on top of the token:
MAILCODED_SEND=1in the environment (trueandyesalso work);- every recipient — To, Cc and Bcc — matching
MAILCODED_APPROVED_RECIPIENTS, a comma-, semicolon- or whitespace-separated list of exact addresses (bob@example.com) or domains (@example.com,*@example.com). An empty or unset list approves nobody, soMAILCODED_SEND=1on its own changes nothing; - room in the budget of five sends per rolling hour, which every agent-surface process on the machine shares.
Every attempt writes an audit row, whether it was allowed or denied.
The TUI connects to the daemon as an editor-style client, which is not an agent surface. It needs the token and nothing else; its confirmation screen is the supervision.
draft
mailcoded draft --to <addr> --subject <text> (--body-file <path> | --body-stdin) [options]
| Option | |
|---|---|
--to <addr> | repeatable; at least one |
--cc <addr>, --bcc <addr> | repeatable |
--subject <text> | required |
--body-file <path> | plaintext body, UTF-8, at most 1 MiB |
--body-stdin | read the body from stdin instead |
--from <addr> | override the account’s own address |
--reply-to <id> | the local message id being replied to; sets In-Reply-To and References |
--in-reply-to <mid> | a raw Message-ID being replied to, without angle brackets |
--account <id> | which account to send as |
$ mailcoded draft --to bob@example.org --subject 'Re: invoice' --body-file /tmp/reply.txt
draft_id: 1
message_id: 24711edcc05243e19fc5ee2ad1440847@example.com
from: you@example.com
to: bob@example.org
subject: Re: invoice
size: 283 bytes
send gate: denied
next: mailcoded send-preview 1
send gate: denied is the gate’s answer right now, so you learn before writing a whole reply chain
that sending is off. draft never prints a token.
reply
mailcoded reply <id> [--all] [--body <text> | --body-file <path>] [--no-quote] [--no-fetch]
Builds a reply draft from an existing message: the recipients (--all adds everyone on the original),
a Re: subject, the quoted original (--no-quote leaves it out), and the In-Reply-To and References
chain that keeps the conversation threaded for the recipient. It fetches the original body if it has
to; --no-fetch stays offline. The result is a draft, exactly as from draft.
send-preview
mailcoded send-preview <draftId>
draft_id: 1
message_id: 24711edcc05243e19fc5ee2ad1440847@example.com
from: you@example.com
to: bob@example.org
size: 283 bytes
send gate: denied
confirm_token: (a long random string)
expires: 2026-09-06T02:10:15.126Z
note: Show this preview to the human and let the human decide before running send-draft.
note: The token is single use, expires at the time shown, and is bound to these exact bytes: ...
Read the recipients. The token is a bearer capability for those ten minutes: whoever can read this output can spend it. Do not paste it anywhere it will be kept.
send-draft
mailcoded send-draft <draftId> --confirm-token <token> [--no-append]
Phase two. By default a copy of the sent message is appended to the account’s Sent folder;
--no-append skips that. Refusals, by exit code:
| Exit | |
|---|---|
| 6 | no valid token: missing, spent, expired, or the draft changed since the preview |
| 4 | a gate is closed — MAILCODED_SEND unset, or a recipient not on the allowlist |
| 5 | over the hourly budget; the error carries retry_after_ms |
| 7 | the SMTP server rejected the credential — mailcoded account reauth |
| 8 | network; safe to retry |
outbox
mailcoded outbox [--state queued|sending|sent|failed]
What is queued, sending, sent or stuck: the first place to look when a send misbehaves.
1 queued bob@example.org
A queued row nobody has previewed is simply a draft. A failed row carries the SMTP reply when
there was one.
What you cannot do yet
There is no attachment upload and no HTML compose in this release. Bodies are plaintext.
8. Accounts
mailcoded account list
mailcoded account test [--account <id|email>] [--no-smtp]
mailcoded account reauth [--account <id|email>] [--client-id <guid>] [--tenant <name>]
mailcoded account add ... (chapter 2)
mailcoded account forget --account <id|email> [--yes]
With one account, --account is implied. With more, name it by id or by address.
account list
1 you@example.com
auth oauth2
imap outlook.office365.com:993
mail 8451 in 12 folders, 37 unread
synced 2026-09-06 01:12:04Z
synced reads never until a folder has completed a sync.
account test
Proves the settings and the credential still work, and changes nothing. It logs in to IMAP and lists
the folders, then — unless you pass --no-smtp — authenticates to SMTP as well. It is the first thing
to reach for when sync starts failing, because it separates “wrong host” from “dead credential”, and
it tests reading and sending separately: IMAP ok with SMTP refused means you can read but not send,
and the detail line says what the SMTP server said.
account reauth
Replaces the credential for an existing account. Its settings and cached mail are untouched.
-
A password account prompts for the new password (not echoed), stores it, and verifies it by connecting and listing folders.
-
An OAuth account runs the Microsoft device-code sign-in again — the same address-and-code flow as
setup(chapter 2), with the same--client-idand--tenantoverrides — then verifies.Credential replaced and verified for account 2.
If the server still refuses, the new credential has nevertheless been stored and you are told so:
The new credential was stored but the server still refuses it: <reason> — exit 7 for an
authentication refusal, 8 for a network failure.
reauth needs a terminal. For scripts, write the credential with account add --password-stdin
using the same --email.
If the TUI is running while you reauth from another terminal, it may keep reporting the old failure for a little while, because the daemon backs off between login attempts. Restart it if it does not recover on its own.
account forget
mailcoded account forget --account <id|email> [--yes]
Removes one account from this machine: its folders, cached messages, search-index entries,
unreferenced blobs, and its stored credential — the password or the OAuth grant, whichever it had.
Use it to undo a mistaken setup, or to stop reading an account here.
Nothing is removed from the mail server. This is not a delete command for mail — there is none — and re-adding the account re-syncs everything.
It asks before acting. For an unattended run you need both --yes and
MAILCODED_ALLOW_FORGET=1, for the same reason move does (chapter 6). The verb is absent from the
MCP surface.
9. Sync and the daemon
sync
mailcoded sync [--account <id>] [--folder <id>]
Connects to the account’s IMAP server and pulls changes: new envelopes, changed Flags, removed
messages. With --folder only that folder syncs. Sync is idempotent — re-running after an
interruption replays safely — and the server is the source of truth for mail and Flags, so a sync can
only ever bring the local copy closer to what the server holds.
Bodies are not pulled by sync. They are fetched on demand — when you open a message in the TUI, rest
on it with the preview open, or read it — and then kept.
Who runs where
mailcoded and mailcoded-mcp open the store directly, in their own process, and exit. The terminal
client is different: mailcoded-tui starts a mailcoded-daemon and talks to it over JSON-RPC.
The daemon holds the IMAP connections open, so the cost of TLS and login is paid once a session rather
than once a keypress, and it can sit in IMAP IDLE waiting for new mail.
One daemon owns a store
A store’s live connections belong to exactly one daemon at a time. Whichever starts first takes an OS
lock on daemon.lock in the data directory; a second daemon on the same store still serves every
request from the shared database, but opens no watch connections, and tells its client so. In the TUI
that appears once as Live updates belong to another mailcoded window; press r to refresh here.; the
daemon itself logs Another live daemon (pid N) owns this store; watch connections stay closed here.
That is why two TUI windows on one store both work but only one of them shows new mail arriving. It is also why the lock is a real OS lock rather than a pid file: when the owner dies, the lock dies with it, and the next daemon takes over without guessing.
Live updates
When the TUI subscribes, the daemon opens one IDLE connection per watched folder — the account’s
configured watch set, which defaults to INBOX — and turns server signals into notifications: new mail
(3 new in INBOX), folder counts, and errors. A bulk operation on the server produces one
notification per folder, not one per message.
An account that cannot authenticate is not watched. If it has no stored credential at all — one
registered with --no-password, or the local account import-eml creates — the daemon says so once:
This account has no stored credential, so it cannot sync. Add one with 'mailcoded account reauth',
or leave it as a local-only store for imported mail.
rather than retrying a login that can never succeed.
Connections, retries and backoff
Every connection is attempted over IPv4 and IPv6 in parallel and the first to answer wins, so a broken
IPv6 route costs about a quarter of a second rather than a timeout. After a failure the daemon backs
off — one second, doubling, to at most five minutes — with a fixed longer pause after an
authentication refusal, so a dead credential does not hammer the server. account test (chapter 8)
tells you what is wrong; S in the TUI shows the last error for each account.
Running the daemon yourself
mailcoded-daemon [--store <path>] [--log-level off|error|warn|info|debug|trace]
mailcoded-daemon --one-shot <method> [--params <json>] [--store <path>]
The default mode speaks Content-Length-framed JSON-RPC 2.0 on stdin and stdout. Every log line goes
to stderr, so stdout carries protocol frames only. --one-shot serves one request given on the
command line and exits, which is a handy way to poke at it:
mailcoded-daemon --one-shot account.list
The protocol — every method, notification, framing rule and error code — is in
docs/rpc.md. The TUI is its reference client; MAILCODED_DAEMON=<path> tells the TUI
which daemon executable to start, otherwise it looks beside itself, then on PATH.
10. Importing mail and raw SQL
import-eml
mailcoded import-eml <dir> [--folder INBOX] [--account <id>] [--recursive] [--email <addr>]
Parses every .eml file in a directory, stores the raw message, indexes subject, sender and body for
search, and threads it. Import is local: nothing is uploaded anywhere.
$ mailcoded import-eml ~/old-mail --recursive
imported 1204 of 1204 file(s) into folder 1; 0 skipped
Files are imported in filename order and keyed by their position, so re-running the same directory updates the same rows instead of duplicating them. Empty files and files over 64 MiB are skipped and listed. Imported messages start unread.
The local account
If the store has no account yet, import creates one to hold the files — local@import.mailcoded.test,
or the --email you give — pointing at localhost:993 with no credential. It is a container, not a
mailbox: it cannot sync, and the daemon says so once rather than trying (chapter 9). Once you add a
real account, pass --account to say which one an import belongs to.
The repository ships 37 anonymised fixtures in fixtures/eml/, which is a good way to try everything
in this manual without a mail server:
mailcoded --db /tmp/mail.db import-eml fixtures/eml
mailcoded --db /tmp/mail.db tui
query --sql
MAILCODED_ENABLE_SQL=1 mailcoded query --sql '<select>' [--max-rows 200] [--read-only]
Read-only SQL against the store. It is off unless MAILCODED_ENABLE_SQL=1 is set:
mailcoded: forbidden (1006): Raw SQL reads require MAILCODED_ENABLE_SQL=1.
hint: Set MAILCODED_ENABLE_SQL=1, or use the search/read/thread verbs instead.
With it, the connection runs under PRAGMA query_only, a single SELECT or WITH statement is
accepted, and the result is capped at --max-rows (1 to 1000, default 200). You never receive a
handle to the database file. --read-only affirms the only mode there is.
$ MAILCODED_ENABLE_SQL=1 mailcoded query --sql 'SELECT folder_id, COUNT(*) AS n FROM messages GROUP BY folder_id'
folder_id n
1 37
[1 row(s), truncated=false]
The tables are accounts, folders, messages, blobs, tags, body_text, outbox and
sync_log. Column names are internal and change with migrations; search, read and thread
are the stable contract. Use SQL for the aggregates the verbs do not expose.
Reading the audit trail
sync_log is append-only and records, among other things, every send attempt — allowed or denied —
every tag change, every body fetch, and every read made from the agent surface. The body of a message
is never written to it.
MAILCODED_ENABLE_SQL=1 mailcoded query --max-rows 50 --json \
--sql "SELECT ts, level, interface, agent_host, event, detail FROM sync_log ORDER BY id DESC"
Or, from your own shell, straight at the file:
sqlite3 -readonly ~/.local/share/mailcoded/store.db \
"SELECT ts, interface, event, detail FROM sync_log ORDER BY id DESC LIMIT 50;"
11. Agents
mailcoded is a mail client first; the agent surface is what falls out of having a good CLI. If you are going to let an AI agent at your mail, read docs/agents.md. It is written for the human making that decision and covers installing for an agent host, the default posture, how to turn the two off-by-default capabilities on, what is audited and where to read it, and the risk you accept. This chapter is the two-minute version.
The shape
| Binary | Surface | Who talks to it |
|---|---|---|
mailcoded | one-shot CLI, --json on stdout | shell-capable agents: Claude Code, Codex CLI, Gemini CLI, opencode, Goose |
mailcoded-mcp | MCP server over stdio | hosts that cannot run a shell: Claude Desktop, Cursor |
mailcoded-daemon | JSON-RPC over stdio | editor-style clients such as the TUI — not an agent surface |
Every safety gate lives in the core library, so the CLI and the MCP server enforce identical rules; you cannot loosen one by choosing the other.
The default posture
| Capability | Default for an agent | Unlock |
|---|---|---|
| search, read, thread | on | — |
| tag | on | — |
| draft | on | — |
| send | off | MAILCODED_SEND=1, MAILCODED_APPROVED_RECIPIENTS, a one-time token, at most 5 an hour |
| raw SQL | off | MAILCODED_ENABLE_SQL=1; read-only and row-capped even then |
| HTML bodies | never | agents get plaintext only |
| move between folders | never on MCP; off on the CLI | MAILCODED_ALLOW_MOVE=1; a terminal then confirms, and --yes skips the prompt |
| delete, expunge, trash | does not exist | there is no such verb or tool |
Set MAILCODED_AGENT_HOST=<label> in the agent’s environment and it is recorded on every audit row.
MCP in one block
dotnet publish src/Mailcoded.Mcp -c Release # or use the installed mailcoded-mcp
Then, in Claude Desktop’s claude_desktop_config.json:
{
"mcpServers": {
"mailcoded": {
"command": "/absolute/path/to/mailcoded-mcp",
"args": ["--db", "/absolute/path/to/store.db"],
"env": { "MAILCODED_AGENT_HOST": "claude-desktop" }
}
}
}
Absolute paths, and restart the client after editing. The server offers eight tools — search,
read, thread, tag, draft, send_preview, send_draft, stats — and no removal tool, by
construction. --db may be omitted in favour of MAILCODED_DB or the default store.
MAILCODED_MCP_LOG sets its stderr log level. It is a local stdio server: it does not reach claude.ai
on the web, or a phone.
12. The safety model, in plain terms
The design documents state these as invariants. This is what they mean for you.
Nothing here deletes mail
There is no delete, expunge, trash or purge anywhere — not in the CLI, the TUI, the MCP server or the
wire protocol — and no setting that adds one. account forget removes an account’s local copy and
nothing on the server. If you want a message gone, move it to your provider’s Trash and let the server
do it. A tool that cannot delete cannot be tricked into deleting, and a fresh sync from the server is
always a way back.
Your mail is treated as hostile input
Every subject, sender and body arrived from a stranger. So:
-
The terminal is protected. Before anything from a message reaches your screen, control characters, escape sequences, invisible characters and the Unicode direction-override characters that can make text read backwards are removed or replaced with
�. If you see that character in a subject, the message tried to put something there that would have driven your terminal. One of the bundled fixtures does exactly that, andreadshows it as harmless text:subject: [2J [HCleared your screenThe escape byte that would have cleared your screen is gone; what remains is inert.
-
No HTML in a terminal. The TUI and the CLI show the plaintext part only. There is no flag for HTML, and the TUI never asks the daemon for it, because a terminal has no sandbox to render it in. Remote images, tracking pixels and the like never load.
-
Filenames are flattened. An attachment is saved under a name the parser made safe, never a path the message supplied, so a message that calls its attachment
../../.bashrcproduces a harmlessly named file in the directory you chose. -
Message text never becomes a command, a query or a path. Database access is parameterised throughout, and nothing from a message is spliced into SQL or a shell.
Credentials
Passwords and sign-in grants live in the OS keyring — or in the encrypted file vault where there is no
keyring — and nowhere else: not in the database, not in the account configuration, not in logs, not in
RPC responses, not in error messages. Passwords are typed at a prompt and never taken as arguments, so
they cannot end up in your shell history. For a password account, setup verifies the login before
storing anything, and a failed attempt stores nothing; a Microsoft sign-in stores its grant as soon as
the browser step completes, before the mailbox is checked (chapter 2).
Sending needs a human
Every send — TUI, CLI or MCP — is two-phase: a preview that lists every recipient and mints a one-time
token, then a send that consumes it. The token is single-use, bound to the exact bytes previewed,
expires in ten minutes, and is never logged or shown by the TUI. The command line is additionally
treated as an agent surface: MAILCODED_SEND=1, a recipient allowlist and a budget of five sends an
hour apply to it (chapter 7). Every attempt is audited, allowed or not.
What it talks to
Your IMAP and SMTP servers. For a Microsoft sign-in, Microsoft’s login endpoint. Nothing else: no telemetry, no update check, no analytics.
Where the guarantees stop
Honest limits:
- The store is a file on your disk with your user’s permissions. Anyone who can read it can read your mail and your audit trail.
- An agent you give read access to can read everything, one query at a time. mailcoded narrows what an agent can do, not what it can see. See docs/agents.md.
- The Microsoft sign-in currently borrows a public client registration (chapter 2). The consent screen names another application, and that registration is outside this project’s control.
13. Troubleshooting
Start with these three; between them they explain most problems:
mailcoded health # store, secret backend, gates, per-account state
mailcoded account test # does the credential still work? IMAP and SMTP, separately
mailcoded stats # counters, the outbox, sends left this hour
In the TUI, S shows the same on one screen, including each account’s last error, and A runs the
connection test.
Messages, and what to do about them
Getting started
| You see | It means | Do |
|---|---|---|
No account is configured. Run 'mailcoded setup' first. | the store has no accounts | chapter 2 |
'setup' is interactive and needs a terminal. | setup was run from a script, or with redirected input | use account add --password-stdin |
'mailcoded-tui' was not found beside this binary or on PATH. | the TUI binary is not installed next to the CLI | scripts/install.sh, or run mailcoded-tui from the build output directly (chapter 1) |
The TUI needs a terminal; stdin or stdout is redirected. | mailcoded tui inside a pipe | run it in a terminal; scripts use the one-shot verbs |
This console cannot render ANSI. Try Windows Terminal. (exit 2) | the legacy Windows console | use Windows Terminal |
Signing in
| You see | It means | Do |
|---|---|---|
a login failure from setup, with hints | wrong host or port, or the provider wants an app password | read the hints; most providers reject the account password over IMAP (chapter 2) |
Microsoft did not return a sign-in code within 45s. Check the network, then try again. | the device-code request did not complete | check connectivity to login.microsoftonline.com; try again |
| the Microsoft consent page names another application | mailcoded borrows a public client registration | expected; use --client-id with your own registration if you prefer (chapter 2) |
account test reports IMAP ok but SMTP auth | you can read; the SMTP server rejected the credential | sending fails until it accepts; reading is unaffected. For Microsoft accounts this is a known open issue at the time of writing |
The new credential was stored but the server still refuses it: ... | reauth stored it; the server said no | the reason follows; exit 7 is authentication, 8 is network |
Syncing
| You see | It means | Do |
|---|---|---|
sync: Timed out connecting to <host>:<port>. | nothing answered at that address | a wrong host, a firewall, or the import-only account (chapter 10) |
This account has no stored credential, so it cannot sync. ... | the account was registered without a credential, or is the local import account | mailcoded account reauth --account <id>, or leave it |
Live updates belong to another mailcoded window; press r to refresh here. | another daemon owns this store’s live connections | normal with two TUI windows; r refreshes (chapter 9) |
Another live daemon (pid N) owns this store; watch connections stay closed here. (stderr) | the same, from the daemon’s side | |
any other red sync: ... | the server refused, or failed | A or account test for the detail; S for the last error |
Still <something>. Press esc to give up on it. | a server operation is slow | wait, or esc to cancel it |
The daemon is gone: ..., TUI exits 3 | the daemon process died | the TUI prints the daemon’s recent stderr as it exits; run it again; mailcoded-tui --check |
Reading
| You see | It means |
|---|---|
(body not fetched) in the reader | the body could not be downloaded just now |
(this message has no plaintext part) | it is HTML-only; there is no HTML view |
� in a subject or body | the message contained control or invisible characters (chapter 12) |
matches were dropped that no cursor reaches | a search hit the paging ceiling; narrow it, or order by date |
Sending
| You see | It means | Do |
|---|---|---|
Sending from the agent surface requires MAILCODED_SEND=1. (exit 4) | the CLI send gate is closed | chapter 7 — or send from the TUI, which is not gated this way |
exit 6, confirm-required | no valid token: missing, spent, expired, or the draft changed | send-preview again |
That confirmation is spent or expired. Preview again. (TUI) | the same | ctrl-s again |
exit 5, rate-limited | five sends this hour already | wait retry_after_ms |
| a recipient refused | not on MAILCODED_APPROVED_RECIPIENTS | add it, or send from the TUI |
outbox shows failed with an SMTP reply | the server rejected the message | the reply says why |
Moving and removing
| You see | It means | Do |
|---|---|---|
Moving mail is outside the default agent posture. ... | move or archive from a script, without MAILCODED_ALLOW_MOVE=1 and --yes | set the variable; add --yes for an unattended run |
Moving mail is not part of the agent surface. (exit 4) | you confirmed at the terminal, but MAILCODED_ALLOW_MOVE is not set — the CLI is an agent surface | MAILCODED_ALLOW_MOVE=1 mailcoded move ..., or use m in the TUI |
This account has no folder marked as Archive. | the server exposes no Archive role | move --folder <name> |
No folder called 'X'. This account has: ... | a typo, or a folder that has not synced yet | pick from the list; sync |
A message can only move within its own account. (TUI) | you picked a folder under another account | pick one under the same account |
| you are looking for delete | there is none, by design | chapter 6 |
Raw SQL
| You see | Do |
|---|---|
Raw SQL reads require MAILCODED_ENABLE_SQL=1. (exit 4) | set it, or use search, read and thread |
Exit codes
| Code | Retry? | |
|---|---|---|
| 0 | success | |
| 1 | internal error | report it |
| 2 | validation — bad arguments | fix the command |
| 3 | not found — no such message, folder, account or draft | |
| 4 | forbidden — a safety gate refused | no; open the gate deliberately, or don’t |
| 5 | rate limited | after retry_after_ms |
| 6 | confirm required — send needs a valid one-time token | preview again |
| 7 | auth — the credential failed or is missing | account reauth |
| 8 | network — transient | yes, with backoff |
| 9 | store — the database is corrupt or full | |
| 10 | unsupported — the server or this build lacks a capability | |
| 130 | cancelled |
Getting more detail
- The daemon logs to stderr. When the TUI exits because the daemon died it prints what the daemon
said; to drive the daemon by hand,
mailcoded-daemon --log-level debug. mailcoded health --jsonandmailcoded stats --jsonare the complete pictures.mailcoded-tui --checkproves the wire without a screen.
Starting over
A fresh sync from the server is always valid. mailcoded account forget --account <id> removes the
local copy — including your local Tags and drafts for that account — and mailcoded setup re-adds
it. If the store itself is damaged (exit 9), move the data directory aside and set up again; the
server still has your mail.
14. Reference
Environment variables
| Variable | Read by | Effect |
|---|---|---|
MAILCODED_DATA_DIR | all | the data directory (chapter 3); --data-dir and --db override it |
MAILCODED_DB | mailcoded-mcp | the path to store.db when --db is not passed |
MAILCODED_DAEMON | mailcoded-tui | which mailcoded-daemon executable to start; mailcoded tui sets it to the sibling it found |
MAILCODED_SEND | CLI, MCP | 1, true or yes opens the agent-surface send gate |
MAILCODED_APPROVED_RECIPIENTS | CLI, MCP | addresses and @domain or *@domain patterns every recipient must match; empty approves nobody |
MAILCODED_ENABLE_SQL | CLI | 1 enables query --sql |
MAILCODED_ALLOW_MOVE | CLI | 1, true or yes; required for move and archive on the CLI at all. A terminal still confirms; --yes skips the prompt |
MAILCODED_ALLOW_FORGET | CLI | the same, for account forget --yes |
MAILCODED_SECRET_BACKEND | all | auto (default), file, libsecret, keychain, wincred |
MAILCODED_SECRET_KEY | all | a passphrase for the encrypted-file vault, instead of the machine key |
MAILCODED_SECRET_KEY_FILE | all | a file holding that passphrase |
MAILCODED_OAUTH_CLIENT_ID | all | your own Microsoft public-client application id |
MAILCODED_OAUTH_TENANT | all | common (default), consumers, organizations, or a tenant GUID |
MAILCODED_AGENT_HOST | CLI, MCP | a label recorded on every audit row |
MAILCODED_MCP_LOG | mailcoded-mcp | its stderr log level |
MAILCODED_TRANSCRIPT and MAILCODED_PARENT_PID are used by the test suite and between the hosts;
they are not for users.
Exit codes and RPC error codes
| Exit | RPC | |
|---|---|---|
| 0 | success | |
| 1 | internal error | |
| 2 | -32602 | validation; invalid params |
| 3 | 1002 | not found |
| 4 | 1006 | forbidden — a gate refused |
| 5 | 1005 | rate limited |
| 6 | 1003 | confirm required |
| 7 | 1000 | auth |
| 8 | 1001 | network |
| 9 | 1004, 1007 | store corrupt; store full |
| 10 | 1008 | unsupported |
| 130 | cancelled |
mailcoded-tui exits 0, or 2 (the console cannot render ANSI), 3 (the daemon went away, or --check
failed), 4 (the daemon refused the connection).
Every key in the TUI
Everywhere — ? help · ctrl-l redraw · ctrl-c quit · esc cancel the innermost thing.
Sidebar and message list
j k ↓ ↑ | move |
g G Home End | first, last |
space PgDn PgUp | a page |
ctrl-d ctrl-u | half a page |
n | the next 100 messages |
tab h l ← → | switch pane; in the sidebar h and l fold |
enter | open the folder; read the message |
/ | search; esc clears it |
r | sync this folder |
c | compose |
p | preview pane on or off |
u f t a m T | unread, flagged, tags, archive, move, thread |
A S o | test the account, status, outbox |
q | quit |
Reader — the scroll keys above · r R reply, reply to all · u f t a m T as above ·
s then a digit to save an attachment · q or esc back.
Move picker — j k choose · enter move here · esc cancel.
Composer — tab shift-tab field · enter a new line in the body, otherwise the next field ·
arrows home end backspace edit · ctrl-s preview · esc or ctrl-c discard, after a y/n.
Confirmation — Y sends · anything else goes back.
Help, status, outbox — any key closes.
Mouse, on Linux and macOS — click to select · double-click to open · wheel to scroll · click a key-bar entry · shift for the terminal’s own selection.
The verbs
setup | add an account, interactively |
search '<query>' | search the local store |
read <id> | one message, as plaintext |
thread <id|key> | one conversation |
attachments <id> [--save <n>] | list them, or save one |
tag <id> +a -b | Tags and Flags |
move <id> --folder <f> | move on the server, confirmed |
archive <id> | move to Archive, confirmed |
folders | folders with counts |
stats, health | counters; state |
tui | the terminal client |
draft, reply <id> | build a draft |
send-preview <draft> | preview, and mint the token |
send-draft <draft> --confirm-token <t> | send |
outbox | queued, sending, sent, failed |
account list, test, reauth, add, forget | accounts |
sync | pull changes |
import-eml <dir> | load .eml files |
query --sql | read-only SQL, gated |
version, help [verb] |
Global options: --json, --data-dir <path>, --db <path>, --quiet, --help.
Files in the data directory
store.db with -wal and -shm · blobs/ · secrets.enc, and on Linux and macOS secrets.key ·
daemon.lock · daemon.owner — chapter 3.
Versions
This manual describes mailcoded 0.1.0, JSON-RPC protocol 1, CLI output schema 1 — what
mailcoded version prints.