Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Installing — build from source, where the binaries go, checking it works
  2. Your first accountsetup, provider quirks, app passwords, Microsoft sign-in, scripted setup
  3. Where your mail lives — the data directory, what is in it, secrets, backups, editors
  4. The terminal clientmailcoded tui: the screen, keys, mouse, reading, triage, compose
  5. Reading and searching from the command linesearch, read, thread, attachments, folders
  6. Triage — Tags and Flags, tag, move, archive, and why there is no delete
  7. Sending — drafts, the two-phase send, the outbox, and who the send gate applies to
  8. Accountsaccount list, test, reauth, add, forget
  9. Sync and the daemonsync, live updates, one daemon per store, running the daemon yourself
  10. Importing mail and raw SQLimport-eml, query --sql, reading the audit trail
  11. Agents — the two-minute version, and where the real document is
  12. The safety model, in plain terms — what mailcoded will never do, and why you may see �
  13. Troubleshooting — the messages you might see, what they mean, what to do
  14. Reference — environment variables, exit codes, every key, every verb, every file

Conventions

  • mailcoded is the command-line tool. mailcoded-tui, mailcoded-daemon and mailcoded-mcp are 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 of search output. 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”).
  • --json output is a contract, versioned by schema_version. The human-readable output is not, and may change between releases.

1. Installing

← Contents

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 develop in 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:

CommandWhat it is
mailcodedthe command-line tool — every verb in chapters 5 to 10
mailcoded-tuithe full-screen terminal client (chapter 4)
mailcoded-daemonthe engine the TUI talks to; JSON-RPC over stdio (chapter 9)
mailcoded-mcpthe MCP adapter for agent hosts that cannot run a shell (chapter 11)

Options:

--prefix DIRinstall root; default $HOME/.local, or $PREFIX
--rid RIDbuild for another runtime identifier, such as osx-arm64 or win-x64
--no-aotinstall the framework-dependent build instead: much faster to install, but needs the .NET runtime present to run
--uninstallremove 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.so from 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/mailcoded is 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

← Contents

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

  1. Your email address. Skip the question with --email you@example.com.
  2. Whether the detected settings look right. Edit them if not. --imap-host, --imap-port, --smtp-host and --smtp-port pre-fill them.
  3. 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.
  4. 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 set MAILCODED_OAUTH_CLIENT_ID. --tenant (or MAILCODED_OAUTH_TENANT) selects common — the default, personal and work accounts — or consumers, 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 setup for 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.com with --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-stdinread the credential from stdin
--no-passwordregister 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

← Contents

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-shmthe 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.keythe 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.lockthe OS lock that makes one daemon the owner of this store’s live connections (chapter 9)
daemon.ownera 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.

PlatformKeyringFallback
Linuxlibsecret — the Secret Service API that GNOME Keyring and KWallet provideencrypted file
macOSKeychainencrypted file
WindowsCredential ManagerDPAPI-protected file
Headless, containersencrypted 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, replied and draft;
  • drafts and the outbox;
  • the sync_log audit 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. Set HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled to 1 (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

← Contents

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 tuithe 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 --checkconnect, 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 are unread/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 is HH:mm for today and Mon dd otherwise. Unread rows are bright and read rows dim. It loads 100 at a time; the status line says n for more when 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; p toggles 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, Endfirst, last
space, PgDn, PgUpa page
ctrl-d, ctrl-uhalf a page
nload the next 100 messages
tab, h / , l / between the sidebar and the list
h / l in the sidebarcollapse, expand — or jump to the parent, into the first child
enteropen the folder; read the message
ctrl-lredraw
qback 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 / Gscroll
r, Rreply, reply to all
u, ftoggle unread, flagged
tedit tags
a, marchive, move
Tshow the whole conversation
s then a digitsave that attachment
q or escback 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
utoggle unreadFlags are pushed to the server in the same call
ftoggle flagged
tedit tagsa prompt, space-separated: triaged followup -inbox adds two and removes one; a leading + is optional
aarchiveto the folder the server marks as Archive; if the account has none, you are told to use m
mmovethe sidebar becomes a picker — j/k to the destination, enter to move, esc to cancel. Within the same account only
Tthreadreplaces the list with the conversation, oldest first; esc goes back
rsync this folder (from the list)the status line reports +added ~updated -removed in N ms
s then 09save an attachmentinto ~/Downloads, under the safe name the daemon assigned; never overwrites — a second invoice.pdf is saved as invoice (2).pdf
Atest this account’s connectionthe result appears in the status line
Sdaemon statusversion, uptime, memory; per account its connection, auth, whether it is being watched, outbox counts and last error
ooutboxqueued, 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-tabnext, previous field
entera new line in the body; the next field elsewhere
arrows, home, end, backspaceedit
ctrl-spreview the send
esc, ctrl-cdiscard — 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

← Contents

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.

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.

SyntaxMatches
invoicethe word, anywhere; diacritics fold, so café also finds cafe
"two words"the phrase
inv*words beginning inv
from:acme, to:bob@example.com, cc:teamthe address or name contains
subject:invoicethe subject contains
tag:triagedcarries the local Tag
folder:INBOX, in:INBOXin that folder
is:unread, is:read, is:flagged, is:unflagged, is:draft, is:repliedserver Flags; seen, starred and answered are accepted synonyms
has:attachmentcarries an attachment
before:2026-01-31, after:2026-01-01, since:2026-01-01strictly earlier; at or after. ISO dates
-term, -tag:spam, not termnegation

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

← Contents

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

← Contents

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:

  1. Draft. Build the message and queue it. Drafting is always allowed, and never sends.
  2. Preview. See exactly who would receive it, and receive a single-use confirm token bound to those exact bytes, valid for ten minutes.
  3. 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=1 in the environment (true and yes also 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, so MAILCODED_SEND=1 on 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-stdinread 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
6no valid token: missing, spent, expired, or the draft changed since the preview
4a gate is closed — MAILCODED_SEND unset, or a recipient not on the allowlist
5over the hourly budget; the error carries retry_after_ms
7the SMTP server rejected the credential — mailcoded account reauth
8network; 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

← Contents

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-id and --tenant overrides — 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

← Contents

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

← Contents

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

← Contents

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

BinarySurfaceWho talks to it
mailcodedone-shot CLI, --json on stdoutshell-capable agents: Claude Code, Codex CLI, Gemini CLI, opencode, Goose
mailcoded-mcpMCP server over stdiohosts that cannot run a shell: Claude Desktop, Cursor
mailcoded-daemonJSON-RPC over stdioeditor-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

CapabilityDefault for an agentUnlock
search, read, threadon
tagon
drafton
sendoffMAILCODED_SEND=1, MAILCODED_APPROVED_RECIPIENTS, a one-time token, at most 5 an hour
raw SQLoffMAILCODED_ENABLE_SQL=1; read-only and row-capped even then
HTML bodiesneveragents get plaintext only
move between foldersnever on MCP; off on the CLIMAILCODED_ALLOW_MOVE=1; a terminal then confirms, and --yes skips the prompt
delete, expunge, trashdoes not existthere 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

← Contents

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, and read shows it as harmless text:

    subject: [2J [HCleared your screen
    

    The 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 ../../.bashrc produces 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

← Contents

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 seeIt meansDo
No account is configured. Run 'mailcoded setup' first.the store has no accountschapter 2
'setup' is interactive and needs a terminal.setup was run from a script, or with redirected inputuse account add --password-stdin
'mailcoded-tui' was not found beside this binary or on PATH.the TUI binary is not installed next to the CLIscripts/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 piperun it in a terminal; scripts use the one-shot verbs
This console cannot render ANSI. Try Windows Terminal. (exit 2)the legacy Windows consoleuse Windows Terminal

Signing in

You seeIt meansDo
a login failure from setup, with hintswrong host or port, or the provider wants an app passwordread 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 completecheck connectivity to login.microsoftonline.com; try again
the Microsoft consent page names another applicationmailcoded borrows a public client registrationexpected; use --client-id with your own registration if you prefer (chapter 2)
account test reports IMAP ok but SMTP authyou can read; the SMTP server rejected the credentialsending 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 nothe reason follows; exit 7 is authentication, 8 is network

Syncing

You seeIt meansDo
sync: Timed out connecting to <host>:<port>.nothing answered at that addressa 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 accountmailcoded 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 connectionsnormal 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 failedA or account test for the detail; S for the last error
Still <something>. Press esc to give up on it.a server operation is slowwait, or esc to cancel it
The daemon is gone: ..., TUI exits 3the daemon process diedthe TUI prints the daemon’s recent stderr as it exits; run it again; mailcoded-tui --check

Reading

You seeIt means
(body not fetched) in the readerthe 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 bodythe message contained control or invisible characters (chapter 12)
matches were dropped that no cursor reachesa search hit the paging ceiling; narrow it, or order by date

Sending

You seeIt meansDo
Sending from the agent surface requires MAILCODED_SEND=1. (exit 4)the CLI send gate is closedchapter 7 — or send from the TUI, which is not gated this way
exit 6, confirm-requiredno valid token: missing, spent, expired, or the draft changedsend-preview again
That confirmation is spent or expired. Preview again. (TUI)the samectrl-s again
exit 5, rate-limitedfive sends this hour alreadywait retry_after_ms
a recipient refusednot on MAILCODED_APPROVED_RECIPIENTSadd it, or send from the TUI
outbox shows failed with an SMTP replythe server rejected the messagethe reply says why

Moving and removing

You seeIt meansDo
Moving mail is outside the default agent posture. ...move or archive from a script, without MAILCODED_ALLOW_MOVE=1 and --yesset 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 surfaceMAILCODED_ALLOW_MOVE=1 mailcoded move ..., or use m in the TUI
This account has no folder marked as Archive.the server exposes no Archive rolemove --folder <name>
No folder called 'X'. This account has: ...a typo, or a folder that has not synced yetpick from the list; sync
A message can only move within its own account. (TUI)you picked a folder under another accountpick one under the same account
you are looking for deletethere is none, by designchapter 6

Raw SQL

You seeDo
Raw SQL reads require MAILCODED_ENABLE_SQL=1. (exit 4)set it, or use search, read and thread

Exit codes

CodeRetry?
0success
1internal errorreport it
2validation — bad argumentsfix the command
3not found — no such message, folder, account or draft
4forbidden — a safety gate refusedno; open the gate deliberately, or don’t
5rate limitedafter retry_after_ms
6confirm required — send needs a valid one-time tokenpreview again
7auth — the credential failed or is missingaccount reauth
8network — transientyes, with backoff
9store — the database is corrupt or full
10unsupported — the server or this build lacks a capability
130cancelled

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 --json and mailcoded stats --json are the complete pictures.
  • mailcoded-tui --check proves 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

← Contents

Environment variables

VariableRead byEffect
MAILCODED_DATA_DIRallthe data directory (chapter 3); --data-dir and --db override it
MAILCODED_DBmailcoded-mcpthe path to store.db when --db is not passed
MAILCODED_DAEMONmailcoded-tuiwhich mailcoded-daemon executable to start; mailcoded tui sets it to the sibling it found
MAILCODED_SENDCLI, MCP1, true or yes opens the agent-surface send gate
MAILCODED_APPROVED_RECIPIENTSCLI, MCPaddresses and @domain or *@domain patterns every recipient must match; empty approves nobody
MAILCODED_ENABLE_SQLCLI1 enables query --sql
MAILCODED_ALLOW_MOVECLI1, true or yes; required for move and archive on the CLI at all. A terminal still confirms; --yes skips the prompt
MAILCODED_ALLOW_FORGETCLIthe same, for account forget --yes
MAILCODED_SECRET_BACKENDallauto (default), file, libsecret, keychain, wincred
MAILCODED_SECRET_KEYalla passphrase for the encrypted-file vault, instead of the machine key
MAILCODED_SECRET_KEY_FILEalla file holding that passphrase
MAILCODED_OAUTH_CLIENT_IDallyour own Microsoft public-client application id
MAILCODED_OAUTH_TENANTallcommon (default), consumers, organizations, or a tenant GUID
MAILCODED_AGENT_HOSTCLI, MCPa label recorded on every audit row
MAILCODED_MCP_LOGmailcoded-mcpits 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

ExitRPC
0success
1internal error
2-32602validation; invalid params
31002not found
41006forbidden — a gate refused
51005rate limited
61003confirm required
71000auth
81001network
91004, 1007store corrupt; store full
101008unsupported
130cancelled

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 Endfirst, last
space PgDn PgUpa page
ctrl-d ctrl-uhalf a page
nthe next 100 messages
tab h l switch pane; in the sidebar h and l fold
enteropen the folder; read the message
/search; esc clears it
rsync this folder
ccompose
ppreview pane on or off
u f t a m Tunread, flagged, tags, archive, move, thread
A S otest the account, status, outbox
qquit

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 pickerj k choose · enter move here · esc cancel.

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

ConfirmationY 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

setupadd 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 -bTags and Flags
move <id> --folder <f>move on the server, confirmed
archive <id>move to Archive, confirmed
foldersfolders with counts
stats, healthcounters; state
tuithe terminal client
draft, reply <id>build a draft
send-preview <draft>preview, and mint the token
send-draft <draft> --confirm-token <t>send
outboxqueued, sending, sent, failed
account list, test, reauth, add, forgetaccounts
syncpull changes
import-eml <dir>load .eml files
query --sqlread-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.