| name | emailops |
| description | Query, search, chat with, and compose/draft/send email from a local EmailOps mailbox via emailops-cli. |
| version | 1.1.0 |
| author | community |
| license | MIT |
| platforms | ["macos","linux"] |
| metadata | {"hermes":{"tags":["Email","AI","Local-First","Privacy","Search","Chat","Compose","Drafts","Send","EmailOps"],"homepage":"https://github.com/emailops/emailops","related_skills":["himalaya"]}} |
| prerequisites | {"commands":["emailops-cli"]} |
EmailOps CLI
emailops-cli is the headless front-end for EmailOps, a privacy-first,
AI-native email client. AI runs locally by default (embedded llama.cpp); email
data lives in a local SQLite database. This skill lets the agent read, search,
chat with, and compose / draft / send mail without opening the desktop app.
It complements the himalaya skill: Himalaya speaks raw IMAP/SMTP, while
emailops-cli adds Gmail/Outlook OAuth accounts plus local AI — semantic
chat over your mail, classification, and embeddings.
The agent contract: always pass --json
Every command supports --json, which prints exactly one stable envelope
to stdout (logs always go to stderr). Parse this single shape regardless of
success or failure — never scrape the human-readable output:
{ "ok": true, "data": { }, "error": null }
{ "ok": false, "data": null, "error": { "code": "not_found", "params": {}, "message": "" } }
Add --quiet to suppress the stderr app-log stream when you only want the
envelope.
Exit codes (branch on these, not on text)
| Code | Meaning | Code | Meaning |
|---|
| 0 | ok | 5 | network / sync |
| 2 | invalid input | 6 | AI |
| 3 | not found | 130 | cancelled |
| 4 | auth | 1 | otherwise |
Prerequisites
emailops-cli on PATH (emailops-cli --version to verify). It is built
from the EmailOps repo with the cli cargo feature; symlink the built
binary into ~/.local/bin if needed.
- An EmailOps data directory with at least one configured account. Confirm
with
doctor before anything else.
When to use
Use whenever the user asks about their own email: searching messages, asking
"what did X say about Y?", listing accounts, reading a specific message, or
checking mailbox health. Also use it to write mail — save or edit a draft,
review existing drafts, or send a message. Prefer the cheap read paths
(doctor, accounts, emails, show, search) over chat unless the
question genuinely needs the local LLM.
Core commands
| Command | Purpose | Cost |
|---|
emailops-cli doctor --json | Env readiness: DB, accounts, AI config. Loads no model. | cheap |
emailops-cli accounts --json | List configured accounts (id, email, provider). | cheap |
emailops-cli emails --json [--limit N] [--mailbox inbox|sent|spam|trash] | List recent emails. | cheap |
emailops-cli show <ID> --json | Full single email (headers + body). | cheap |
emailops-cli search '<query>' --json [--limit N] [--trace] | Full-text search across an account's mail. | cheap |
emailops-cli chat '<question>' --json [--trace] | Ask the local AI a question against your mail. | loads LLM |
emailops-cli stats --json | Per-account dashboard totals + coverage. | cheap |
emailops-cli compose --to … --subject … (--body …|--body-file …) --json | Save a draft (default) or --send to deliver. | cheap (write) |
emailops-cli drafts --json | List saved drafts, newest-first. | cheap |
emailops-cli draft <ID> --json | Show a single draft (recipients, subject, body, attachments). | cheap |
Scope to an account with --account <ID|EMAIL> (defaults to the single
enabled account). When multiple accounts exist, list them first and pass
--account explicitly.
Procedure
-
Health check first (read-only, no model load):
emailops-cli doctor --json
Confirm data.ok == true and data.accountsEnabled > 0. If aiEnabled
is false, avoid chat and stick to search/read commands.
-
Resolve the account when there's more than one:
emailops-cli accounts --json
Use the returned id or email with --account.
-
Read paths for lookups (fast, safe even while the desktop app is open):
emailops-cli search 'invoice from acme' --json --limit 5
emailops-cli show <id> --json
emailops-cli emails --json --mailbox inbox --limit 25
-
AI path for natural-language questions (loads the local model):
emailops-cli chat 'what did the landlord say about the lease renewal?' --json
Add --trace to inspect routing, retrieval, tool calls, and the sources
behind the answer under data.trace.
-
Multi-turn: carry context across one-shot invocations by passing the
conversationId from a previous chat --json result:
emailops-cli chat 'and what date was that?' --json --conversation <conversationId>
-
Parse the envelope, branch on ok / exit code, and answer the user
from data. Surface error.message on failure.
Writing mail: compose, drafts, send
compose is the single write path. By default it saves a draft (and, on
Gmail/Outlook accounts, also pushes it to the provider's Drafts folder — no
extra flag). Add --send to deliver immediately instead. Both paths return the
resulting record under data.
🚫 NEVER send without explicit user confirmation. Do not run compose … --send until you have shown the user the full email about to be sent —
the sending account, every To/Cc recipient, the subject, the complete
body, and any attachments — and the user has explicitly approved that exact
message. No exceptions: not for "obvious" replies, not because the user said
"send it" earlier about a different draft, not to save a round-trip. A vague
or prior "go ahead" is not sufficient — re-confirm after showing the final
content. When unsure whether you have approval, save a draft and ask — you
never need permission to save a draft, and you always need it to send.
emailops-cli --account <ID|EMAIL> compose \
--to a@x.com --to b@y.com --cc c@z.com \
--subject "Q3 numbers" \
--body "Hi — draft here." \
--attach /abs/path/report.pdf --json
emailops-cli --account <ID|EMAIL> compose \
--to a@x.com --subject "Notes" --body-file /abs/path/body.txt --json
emailops-cli --account <ID|EMAIL> compose --draft <DRAFT_ID> \
--subject "Q3 numbers (rev)" --body "Updated." --to a@x.com --json
emailops-cli --account <ID|EMAIL> compose \
--to a@x.com --subject "Q3 numbers" --body "Final." --send --json
Rules and shapes:
--to, --cc, --attach are repeatable; pass the flag once per value.
- Body is
--body XOR --body-file. Supplying both is invalid input
(exit 2, no JSON envelope — it's rejected at the argument layer). --body-file
reads the body verbatim from the path.
- Use real line breaks — write bodies like a normal email. The CLI stores
the body verbatim and does not interpret escape sequences, so a plain
--body "Hi\n\nText" is stored as the literal characters \n and the
recipient sees \n in the message. Format the body the way a person would:
greeting on its own line, a blank line between paragraphs, and a blank line
before the sign-off. Two reliable ways to pass actual newlines:
--body-file (preferred for anything multi-line): write the body to a
file with real newlines, then reference it —
printf 'Hi there,\n\nFirst paragraph.\n\nThanks,\nGero\n' > /tmp/body.txt
then compose … --body-file /tmp/body.txt.
- ANSI-C quoting for short inline bodies:
--body $'Hi there,\n\nText'
(the $'…' form makes the shell expand \n into real newlines; ordinary
"…" double quotes do not).
- Attachments are path references, resolved at compose/send time. Use
absolute paths readable now;
filename and mimeType are inferred. Each
returned attachment is {id, draftId, filePath, filename, mimeType}.
compose (draft) returns the draft object: id, accountId, status
("draft"), subject, toAddresses[], ccAddresses[], body, attachments[],
providerDraftId (null on IMAP/local; set once pushed to Gmail/Outlook),
createdAt/updatedAt. drafts returns an array of these (newest-first);
draft <ID> returns one.
- Drafts don't require recipients (a bare draft with empty
toAddresses is
saved fine) — but confirm the recipient list before --send.
Review drafts:
emailops-cli --account <ID|EMAIL> drafts --json
emailops-cli --account <ID|EMAIL> draft <DRAFT_ID> --json
Mandatory send procedure (never skip a step)
--send is irreversible and there is no undo. Every send MUST follow this
order:
- Save it as a draft first (
compose …, no --send) — never build the
send command straight from user text.
- Show the user the full outgoing email by reading it back with
draft <ID> --json: the sending account, all To/Cc recipients, the
subject, the entire body, and the filename of each attachment. Present it
plainly, not just a summary.
- Ask for explicit approval of that exact message and wait for a clear
"yes, send this". If the user asks for any change, edit the draft
(
compose --draft <ID> …) and repeat from step 2 — approval of an earlier
version does not carry over.
- Only then run
compose --draft <ID> --send --json (or an equivalent
--send with the identical, already-approved fields).
If you cannot show the full message and get a fresh, explicit yes, do not
send — leave the draft saved and tell the user it's ready to review.
Pitfalls
-
Heavy writes need the app closed. sync, classify, embed, and the
heavy compose write paths — --send, and provider draft push on
Gmail/Outlook — mutate the SQLite DB (and hit the network); run them only
when the EmailOps desktop app is not running (WAL contention). A plain
save-as-draft on an IMAP/local account is safe even while the app is open
(the draft stays local — providerDraftId is null). Read commands are always
safe.
-
Don't scrape pretty output. Human output is styled (color/tables) only on
an interactive TTY and is not a stable contract. Always --json.
-
Logs are on stderr. Don't merge stderr into stdout when parsing JSON;
use --quiet to silence the app-log stream.
-
No model for doctor. It's intentionally cheap — use it as the gate
before any AI command rather than calling chat blindly.
-
Never send without explicit user confirmation. --send is irreversible;
always follow the Mandatory send procedure above — save a draft, show the
full email (account, all recipients, subject, body, attachments), get a fresh
explicit yes, then send. When in doubt, leave it as a draft.
-
Same commands in the REPL. Running emailops-cli with no subcommand opens
an interactive REPL that exposes these as /compose, /drafts, and
/draft <id>. For the agent, prefer the one-shot subcommands with --json.
-
Double-encoded saved files. When emailops-cli output is written to a file
and ends up double-escaped (the outer JSON has an "output" key whose value
is a string containing the inner JSON), unwrap it first:
cat saved_result.json | jq -r '.output' | jq ...
Without -r .output, jq will try to parse the escaped string as literal
JSON tokens and silently fail or return wrong results.
-
Outlook/Hotmail control characters break jq. Emails from Outlook/Hotmail
providers can inject raw \r\n sequences that cause jq: parse error: Invalid string: control characters … errors. Pre-filter before piping to jq:
cat output.json | sed 's/\\r//g' | jq ...
Verification
emailops-cli doctor --json
emailops-cli accounts --json
If doctor returns ok:true with at least one enabled account, the skill is
correctly wired and downstream commands can be trusted.