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, and if nothing matches every word the search is retried once with OR
rather than answering nothing. You will see a line saying so above the hits, and relaxed: true in
the JSON. Ranking does the rest: a message matching every word still comes first. OR is not
something you can type; typing 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.
Relevance weighs a hit in the subject far above the same word in a body, so a message whose subject names what you are looking for wins over one that merely repeats the word. A hit in the sender address counts for more than one in a body, and less than one in a subject.
$ mailcoded search 'invoice roof'
~ nothing matched every word, so these match some of them, best first.
14 2025-01-19T08:00:00.000Z [unread] accounts@example.com
Invoice 4471
304 2026-09-06T05:17:41.000Z [-] "Freelancer" <noreply@notifications...>
Andrew, these PHP, HTML, and JavaScript projects and contests might interest you
2 hit(s), truncated=false
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.
Searching by meaning — unfinished, and there is nothing to install yet. The --meaning flag
exists and the machinery behind it runs, but no model is shipped or pinned, so on any checkout
today it has nothing to rank with:
$ mailcoded search 'the leaking roof thing' --meaning
~ no model is installed, or nothing has been embedded yet; these match on words alone.
scripts/install.sh --with-model is where a model would come from, and it currently refuses:
the URL and checksum it would verify are empty, so it prints “no model is pinned in this checkout”
and installs nothing. Until a licence has been read and a digest recorded, that is the whole story —
there is no download to accept, and nothing to delete afterwards.
What the flag does when a model is present: it embeds your query, ranks every embedded message by
how close it sits to that query, and fuses that ranking with the ordinary word search, which stays
primary — a message both stages found beats one only a single stage found. The JSON reply then
carries semantic: true. Whether that ordering is any better than plain search is unmeasured.
The pipeline has only ever run against a test model with random weights, which proves the plumbing
and says nothing about relevance, so treat the feature as unfinished rather than as a way to find
things. mailcoded search 'roof' is what to use meanwhile.
Three things to know if you do put a model in place:
- It fills the page. Every embedded message is a candidate, so a word search that found one message can come back with fifty. There is no relevance threshold, because choosing one needs a real model to calibrate against. Read down until the results stop making sense.
- A fused page has no cursor. The two rankings interleave, so a later full-text page would repeat
what fusion promoted. Page with
--order dateif you need to walk a large result set. - Only messages whose body has been fetched have a vector, and they are embedded in the background by the daemon. Nothing is findable by meaning until a daemon has run.
It is a flag rather than the default because it costs one model load per command, and mailcoded is
a command an agent runs in a loop. A long-lived daemon loads the model once at startup instead, so a
client that talks to the daemon would not pay that per search.
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.