Commands
Complete reference for CLI commands and TUI key bindings.
TUI
Running mp with no arguments launches a full-screen terminal interface
built on ratatui: a mailbox sidebar, the message list, and the headers and body of the selected message.
The sidebar hides below 40 columns and the right panes below 80.
Like the CLI and the Mac app, the TUI is a client of the local daemon, which syncs every account and pushes new mail to it.
Selectors
Every command that names a message takes a selector, never a file path:
mp://<account>/<mailbox>/<key>. For received mail the key is the
Message-ID without its angle brackets; for a draft the mailbox is the reserved name
drafts and the key is the id: field in the draft's frontmatter,
so renaming the file does not change its selector.
The leading segments can be left off: the account falls back to -A/--account
or the first account in your config, and the mailbox to --mailbox or the
command's own scope. mp send 9f2c1ab30d5e4471 and
mp send mp://work/drafts/9f2c1ab30d5e4471 are the same command. A key that
matches messages in two mailboxes is reported with both full selectors rather than
resolved by guesswork; --mailbox picks one. Every command that creates or
lists something prints the fully qualified form, which pastes straight into the next
command, and mp path <selector> is the one way back to a filesystem path.
On a terminal, mp new, mp edit, mp reply,
mp forward, mp mark-approved and mp mark-draft print
✓ <selector>, mp archive prints the selector it moved
the message to under ✓ archived, and mp save prints
✓ <path> per file. When stdout is a pipe or a file, each prints
only the bare selector or path, one per line, so
sel=$(mp new note) && mp path "$sel" works as written.
Draft Commands
| Command | Description |
|---|---|
mp <selector> | Preview a draft (dry-run) |
mp new <name> | Create a new draft from template and print its selector |
mp list [--status draft|approved|sent] [--json] | List the account's drafts (--json prints them as JSON on stdout) |
mp validate [<selector>] | Validate one draft, or every draft of the account |
mp path <selector> | Print the filesystem path of a draft |
mp edit <selector> | Open a draft in $EDITOR |
mp mark-approved <selector> | Mark draft as approved |
mp mark-draft <selector> | Demote approved draft back to draft |
mp delete <drafts-selector> [--force] | Delete a draft (file + index row); --force is required for an approved draft |
mp delete --sent | Clear every sent draft of the account |
mp send <selector> [-y] | Send a single approved email |
mp send --invite ... | Send a calendar invitation (iMIP REQUEST) |
mp invite accept|tentative|decline <selector> | RSVP to a received invitation (iMIP REPLY) |
mp calendar rebuild [-A NAME] | Report the attendee RSVPs resolved against your sent invites (every account unless -A names one) |
mp send-approved [--all-accounts] [-y] | Send every approved draft of the account |
mp outbox list [--json] | Show queued, retrying, failed and partly delivered submissions (--json prints them as JSON on stdout) |
mp outbox retry <id> | Send a failed submission again |
mp outbox discard <id> | Drop a submission and release its bytes |
mp reply <selector> [--all] [--mailbox <name>] | Create a reply draft and print its selector |
mp forward <selector> [--mailbox <name>] | Create a forward draft and print its selector |
IMAP Commands
| Command | Description |
|---|---|
mp fetch [filters] | Fetch emails from server |
mp sync [options] | Sync local folders with server |
mp watch [options] | Watch mailbox for changes (IMAP IDLE) |
mp list-mailboxes [--json] | List available server mailboxes (--json prints them as JSON on stdout) |
mp archive <selector> [--mailbox <name>] | Archive a received email (server + local) |
mp delete <selector> [--mailbox <name>] | Delete a received email (server + local), or a local draft with a drafts selector |
mp search <query> [--json] | Search emails on the server with one grammar (fields, has:attachment, dates, OR groups); --local searches the store offline; --json prints the hits as JSON on stdout |
mp open <selector> [--mailbox <name>] | Open attachment in default app |
mp save <selector> [--output <dir>] [--mailbox <name>] | Save attachment(s) to directory, fetching the message from the server first when the store lacks it and the selector names its mailbox |
Local Store Commands
Offline reads over what mp sync already brought down. They open no
connection: they answer from the per-account store, so they work on a plane and cost
nothing. The one exception is mp show of a message the store does not hold,
below.
| Command | Description |
|---|---|
mp list-messages [--mailbox <name>] [-n N] [--json] | List received messages from the local store (offline; --json prints them as JSON on stdout) |
mp show <selector> [--mailbox <name>] [--json] | Print one received message (headers, attachments and body) from the local store, fetching it from the server first when the store lacks it and the selector names its mailbox |
mp search --local <query> [--mailbox <name>] [-n N] [--full] [--json] | Ranked full-text search over the local store (offline, every synced mailbox at once) |
| Command | Description |
|---|---|
--mailbox <name> | Mailbox to list (role, slug or sidebar label). Default: every mailbox of the account, grouped |
-n, --limit <N> | Max messages per mailbox listed (default: 20) |
-A, --account <name> | Account to read (default: first in config) |
--json | Emit one JSON object instead of the human layout (mp show: the message; mp list-messages and mp search: the listing, with selectors and ISO 8601 dates) |
mp list-messages prints one line per message with its full selector, which
pastes straight into mp show, mp reply or mp archive.
With no --mailbox it lists every mailbox of the account, grouped, and the
limit applies per mailbox so a busy inbox cannot hide the others. The order is the one the
TUI's flat list shows (tc switches an account to it).
mp show takes the same selector grammar as every other message command, so
the bare Message-ID is enough when it is unambiguous, and an
mp://<account>/... selector reads that account's store rather than the
default one. A selector that names its mailbox but no message the store holds, such as a
sent message mp search found on the server that no sync stored, is
fetched from the server into the store first, with a note on stderr; mp save
and mp open do the same. A body the store no longer holds (evicted, or never
ingested) prints as a note under the headers instead of failing. --json is the parseable form:
one object with the envelope, the attachment list and the body as a JSON string.
mp dump-mailbox --json is a different tool with a similar shape: it is the
parity oracle, emits newline-delimited envelopes without bodies for every account, and its
record shape is pinned. Read mail with these two; diff stores with that one.
Maintenance Commands
| Command | Description |
|---|---|
mp hooks list [--json] | List the mail hooks of [[accounts.hooks]] with their cursor and last run (every account unless -A names one) |
mp hooks test <hook> <selector> [--json] | Check a hook against a stored message: each match criterion's verdict, the authenticated sender among them; runs nothing |
mp hooks replay <hook> <selector> | Run a hook for a stored message now, in the daemon, behind the same match table; the cursor does not move |
mp store gc [--dry-run] [--force] [--all-accounts] | Run the retention sweep now: evict cached blobs over the disk cap. The first over-cap run only warns and records a marker; run it again to evict. --dry-run prints what would go without touching anything; a plan reclaiming more than half the store is refused without --force. The sweep also runs automatically after every sync. |
mp cutover [-A NAME] [--dry-run] | End the pre-1.0 file-era layout: give any draft that has none an id: field, and report the old .md mailbox directories nothing reads any more |
Mailypoppins used to keep received mail as one Markdown file per message under
~/.local/share/mailypoppins/accounts/<account>/. It now keeps it in a
SQLite store beside a content-addressed blob store, and those old
inbox/, archive/ and sent/ directories are dead
weight. mp cutover is the one-time tidy-up: it lists each dead directory with
its size and prints the rm line that removes it.
It never deletes anything itself, and there is no migration on startup: it runs only
when you run it. Its one write is the id: frontmatter field, which is how a
draft written by the old build becomes addressable by selector; running it again writes
nothing, and --dry-run writes nothing at all. Drafts are the only local-only
data in the product, so they are the only thing the old directories hold that the server
cannot give back, and they were never in those directories to begin with.
Config Commands
| Command | Description |
|---|---|
mp config init | Interactive setup wizard |
mp config show | Display config (passwords masked) |
mp config set-password | Store a password in the active secrets backend (first account unless -A names one) |
mp config reset-secrets | Wipe the encrypted secrets file and OAuth2 token caches, then re-prompt for credentials (after a restore to a new machine, or when the secrets file no longer decrypts) |
mp config add-account | Add a new account to the existing config |
mp config oauth2-login | Run OAuth2 device code flow (first OAuth2 account unless -A names one) |
mp config path | Print config file path |
Daemon Commands
| Command | Description |
|---|---|
mp daemon run [--foreground-logs] | Run the daemon in the foreground; never connects to another daemon |
mp daemon start [--timeout-secs N] | Start the daemon (through the login service when one is installed for this data directory, detached otherwise) and return once it answers |
mp daemon status [--json] | Report whether a daemon is running against this data directory |
mp daemon stop [--grace-secs N] [--timeout-secs N] | Ask the running daemon to shut down, letting work in flight settle for the grace |
mp daemon restart [--grace-secs N] | Stop the running daemon and start this executable's, through the login service when one is installed, so a systemd unit or launchd agent stays in charge |
mp daemon install-service [--check] [--force] | Install the login-start service (systemd user unit or launchd agent) and enable it; --check reports and writes nothing |
mp daemon uninstall-service | Disable the login-start service and remove its file |
mp daemon health [--json] | Report the running daemon's health, its counters and its checks |
mp daemon logs [--lines N] [--level LEVEL] [--json] | Print the tail of the daemon's own structured log |
mp daemon support-bundle [OUT] [--no-redact] | Write a redacted support bundle and say what went into it |
mp account list | List the configured accounts, their backend and their state |
Every command that touches your mail asks a background daemon over a Unix socket, and
starts one if none is running. These manage it directly; mp daemon * never
starts a daemon it was only asked to report on.
Shell Completions
mp completions <bash|zsh|fish|elvish|powershell> prints a completion
script for every command and flag on stdout. It reads no config and needs no daemon.
# zsh: save it on your fpath, then restart the shell
mp completions zsh > "${fpath[1]}/_mp"
# bash: load it in ~/.bashrc
eval "$(mp completions bash)" Contacts Commands
| Command | Description |
|---|---|
mp contacts search [<query>] [--parsable|--json] [-n N] | Fuzzy-search the contact index by name and email; --parsable emits tab-delimited email/name lines for mutt, aerc or vim, --json one JSON object with the counts (default limit: 20) |
mp contacts rebuild | Rebuild the contact index from the local message store (every account unless -A names one) |
mp contacts stats | Show contact index statistics |
Fetch Options
| Command | Description |
|---|---|
--from <addr> | Filter by sender |
--to <addr> | Filter by recipient |
--cc <addr> | Filter by CC |
--subject <text> | Subject contains |
--body <text> | Body contains |
--since <YYYY-MM-DD> | Emails since date |
--before <YYYY-MM-DD> | Emails before date |
-n, --limit <N> | Max results (default: 10) |
--full | Show full body instead of preview |
--mailbox <name> | Mailbox name (default: INBOX) |
Sync Options
| Command | Description |
|---|---|
-n, --limit <N> | Max messages per mailbox (default: 50) |
--mailbox <name>... | Mailboxes to sync (default: INBOX, Archive, Sent) |
--dry-run | Show what would change without modifying files |
--all-accounts | Sync every configured account (names the failures, exits 1 if any failed) |
Sync is additive by default: it fetches new emails only, deduplicated by Message-ID. A two-pass strategy first downloads headers (~50 bytes per message) to identify new emails, then fetches full RFC822 bodies only for messages not already stored locally.
Deletes are handled by the same pass. The download is capped by --limit,
the enumeration never is, so a message the server no longer lists is pruned from the
local store however far it sits below the newest ones, and a mailbox emptied elsewhere
does not linger.
Invite Options (mp send --invite)
mp send --invite sends an iMIP calendar invitation
(METHOD:REQUEST) over SMTP that renders as an actionable
Accept / Tentative / Decline event in Outlook, Gmail, and Apple Mail.
Attendees come from --to/--cc and the subject
is reused as the event summary. Times accept local wall-clock
(2026-07-20T14:00, "2026-07-20 14:00") or
RFC3339 with an offset (2026-07-20T14:00:00+02:00,
...Z); a timed event needs either --end or
--duration. The ORGANIZER is the sending
account's primary address (Exchange drops mismatches). Graph accounts
are not yet supported for invites.
A bare date as --start sends an all-day event, which
calendars show in the all-day row. Without --end it covers
that one day. --end names the last day, inclusive, so
--start 2026-12-24 --end 2027-01-08 covers 24 December
through 8 January; the invitation carries the day after as its
exclusive DTEND, as RFC 5545 requires.
To change an invitation already sent, send it again with
--uid set to its UID (shown in the preview
and in its invite.ics) and a higher --sequence
(the first send is 0, so the first update is 1). Calendar clients
replace the event only when the UID matches and the sequence is higher
than the one they hold; without --uid the invitation is a
new event.
mp send --invite --to me@example.com --subject "Kita closed" --start 2026-12-24
mp send --invite --to me@example.com --subject "Christmas holidays" --start 2026-12-24 --end 2027-01-08
mp send --invite --to me@example.com --subject "Kita closed" --start 2026-12-24 \
--uid 3f2a...@example.com --sequence 1 | Command | Description |
|---|---|
--invite | Send an iMIP calendar invitation (METHOD:REQUEST) |
--to <addr> | Attendee(s), comma-separated (also the To header) |
--cc <addr> | CC attendee(s), comma-separated |
--subject <text> | Event summary (reused as the email subject) |
--start <when> | Start: local time or RFC3339 with offset; a bare date (2026-12-24) makes an all-day event |
--end <when> | End, the same kind as --start; for an all-day event the last day, inclusive |
--duration <dur> | Duration instead of --end: PT1H30M or 1h30m; whole days (P3D, 3d) for an all-day event |
--location <text> | Optional event location |
--description <text> | Optional event description / body |
--uid <uid> | Re-send an invitation already sent, as an update of the event with this UID |
--sequence <n> | The update's SEQUENCE, higher than the last one sent for the UID (needs --uid) |
RSVP to Invitations (mp invite)
mp invite accept|tentative|decline <selector> replies
to a received calendar invitation. It reads the invite email's sidecar
invite.ics (the source of truth for UID/SEQUENCE),
builds a METHOD:REPLY with your account as the single
ATTENDEE carrying the chosen PARTSTAT
(ACCEPTED/TENTATIVE/DECLINED), and
emails it to the ORGANIZER with an Outlook-convention
subject (Accepted: <summary>, etc.). On a successful
send the local event.rsvp is updated. v1 answers the whole
series only (no per-occurrence responses) and adds no note text.
In the TUI, invites show a calendar badge in the list and an event
summary card at the top of the preview pane; press tv to open
an Accept / Tentative / Decline overlay. The RSVP is emailed to
the organizer only — it is not synced to your Exchange server calendar
(no Graph in v1). Graph accounts are not yet supported for RSVP.
Organizer RSVP Tracking (mp calendar rebuild)
When someone accepts or declines an invitation you sent with
mp send --invite, their reply arrives as a
METHOD:REPLY email during normal sync. mailypoppins matches
it by event UID to your sent invitation and shows that
attendee's status on the invite, so you can see who
responded. The statuses are computed from the stored invitation and
reply messages every time they are shown, so the agenda and the preview
card are always current with what has arrived.
mp calendar rebuild [-A NAME] reports what those
replies resolve across every stored invitation (defaults to all
accounts). It writes nothing: there is no cached copy of the statuses to
rebuild, so it is idempotent by construction, and two machines syncing
the same account see identical state with no direct machine-to-machine
sync. Replies for a SEQUENCE older than the invitation's are
ignored, and the most recent reply per attendee wins.
Nothing is written back to the server — without Graph, your
server-side Exchange calendar is never touched.
Global Options
| Command | Description |
|---|---|
-A, --account <name> | Account to use (default: first in config; mp calendar rebuild, mp cutover and mp contacts rebuild cover every account unless it names one) |
-s, --signature <name> | Use a specific signature |
--no-signature | Skip signature entirely |
Search Query Syntax
The mp search command, its --local mode and the TUI
server search overlay (f) all speak one grammar.
Fields AND together, (a OR b) groups an alternation, and quoted
values are supported (e.g., from:"John Doe"). Sylvain's example
from:boss@corp.com (invoice OR receipt) has:attachment runs on
every backend. Without an in: prefix or --mailbox, all
the account's mailboxes are searched. A malformed query is an error with a
caret pointing at the problem, never a silent search for less.
| Command | Description |
|---|---|
from: | Filter by sender |
to: | Filter by recipient |
cc: | Filter by CC |
subject: | Filter by subject |
body: | Filter by body content |
filename: | Match an attachment filename (Gmail/Exchange/--local only) |
has:attachment | Only mail carrying an attachment |
after: | On or after this date, YYYY-MM-DD (since: is an alias) |
before: | Strictly before this date, YYYY-MM-DD |
message-id: | Exact RFC 5322 Message-ID lookup; angle brackets optional |
in: | Restrict search to a mailbox |
"a phrase" | A quoted phrase is one term |
(a OR b) | An OR group; AND-ed with the rest of the query |
bare text | Matches any field |
Flags (same query, for scripting)
Every field has a matching flag; a flag invocation builds the identical
query as its positional twin, so
mp search --from boss@corp.com --has-attachment 'invoice OR receipt'
equals mp search 'from:boss@corp.com (invoice OR receipt) has:attachment'.
| Command | Description |
|---|---|
--from / --to / --cc / --subject / --body / --filename <s> | The field prefixes as flags; they build the same query |
--has-attachment | Only mail carrying an attachment (has:attachment) |
--after / --before <YYYY-MM-DD> | The date range as flags |
--mailbox <name> | Scope to one mailbox (per-account only) |
-n, --limit <N> | Max results (default: 20) |
--full | Show full body instead of preview |
--local | Search the local FTS index offline instead of the server |
--json | Print the hits as one JSON object on stdout; warnings stay on stderr |
has:attachment across backends
On Gmail and Microsoft Exchange has:attachment runs server-side.
Plain IMAP (RFC 3501) has no attachment search key, so mp
answers that part from the local store's synced mail and prints a warning that
un-synced mail is not covered; run mp sync for full coverage.
The result header on stdout repeats it, as in
Fetched Emails (3 results, local store only: has:attachment) or
No results found (local store only: has:attachment), so a
script that drops stderr still sees it, and --json carries
"coverage": "local" with the term in local_terms
("coverage": "server" when the server answered every term).
The TUI's server search overlay and the desktop app's search bar put the
same caveat after their count, as in
2 results (local store only: has:attachment).
Local full-text search (--local)
mp search --local <query> answers from the local store's
FTS5 index instead of the server: offline, ranked best match first, and
covering every synced mailbox of the account at once. A subject hit
outranks the same word buried in a quoted reply. The index is written in
the same transaction as the message it describes, so it is never stale
with respect to what mp sync has pulled, and it holds only
what has been synced, which is what the server-side search is still for.
Punctuation is searched as text, not as query syntax.
| Command | Description |
|---|---|
word | Matches the whole word in subject, sender or body; several words all have to match |
"a phrase" | Matches the words adjacent, in that order |
word* | Prefix match (invoi* finds invoicing) |
subject: | Restrict the term to the subject |
from: | Restrict the term to the sender |
body: | Restrict the term to the body text |
--mailbox <name> | Restrict the search to one mailbox (default: the whole account) |
TUI Key Bindings
Generated from the single KEYMAP source of truth
(clients/tui/src/app/keymap.rs) via mp dump-keys --json;
see scripts/regen-website-keys.sh. These match the in-TUI
help overlay (?) exactly.
The copy keys (y, and c in Contacts) write to the system
clipboard. Over ssh the remote host usually has none, so the TUI sends the text to
your terminal as an OSC 52 escape sequence instead. Ghostty, WezTerm, kitty, iTerm2
and tmux with set-clipboard on put it on your local clipboard. The
status line then shows the copied text, so you can still select it by hand in a
terminal that ignores OSC 52.
GLOBAL
| Key | Action |
|---|---|
| q | Quit |
| 1-9 | Jump to mailbox |
| Tab | Cycle focus forward |
| Shift+Tab | Cycle focus backward |
| ? | Toggle this help |
| : | Command palette (run an action by name) |
| Ctrl+p | Command palette (run an action by name) |
| z | Zoom / unzoom the focused pane |
| ! | Toggle activity log |
| x | Send current draft (approve + send) |
| ff | Search all mail (sender, subject, body) |
| cn | New draft |
| cs | Manage signatures |
| gm | Go to mailboxes (sidebar) |
| ga | Switch account |
| ss | Quick sync |
| sS | Full sync |
| sl | Activity log overlay |
| sc | Open config.toml in $EDITOR |
| sf | Open log file in $EDITOR |
| Space m | Switch to Mail view |
| Space c | Switch to Contacts view |
| Space a | Switch to Calendar view |
MESSAGE (list, headers, body)
| Key | Action |
|---|---|
| J / K | Next / previous message |
| Enter | Read (focus the body pane) |
| e | Open in editor (mail read-only) |
| r | Reply |
| a | Archive |
| d | Delete |
| u | Toggle read/unread |
| * | Toggle flag/star |
| M | Move to mailbox (fuzzy picker) |
| y | Copy selector (mp://) |
| Esc | Clear selection / return to list |
| gj / gk | Next / previous message |
| fm | Filter the current list |
| cr | Reply |
| ca | Reply all |
| cf | Forward |
| tt | Show conversation (thread) |
| tc | Conversation view on / off for this account |
| to | Open attachment |
| ts | Save attachment to disk |
| tb | Open HTML in browser |
| tv | RSVP to invitation (Accept/Tentative/Decline) |
SIDEBAR
| Key | Action |
|---|---|
| j/k | Navigate mailboxes |
| Enter | Select mailbox |
EMAIL LIST
| Key | Action |
|---|---|
| j/k | Navigate emails |
| gg / G | Jump to top / bottom |
| Ctrl+d / Ctrl+u | Half-page down / up |
| PgDn / PgUp | Page down / up |
| Home / End | Jump to top / bottom |
| gt | Jump to date (e.g. last week) |
| v | Toggle selection |
| Ctrl+a | Select all visible |
| l | Expand the conversation (conversation view) |
| h | Collapse the conversation (conversation view) |
| ce | Edit recipients (in Drafts or a conversation) |
| ta | Attach file to draft (Drafts only) |
| cA | Approve draft (in Drafts or a conversation) |
| cD | Unapprove, back to draft (in Drafts or a conversation) |
| cX | Send all approved drafts (Drafts only) |
| fF | Show flagged only (toggle) |
SERVER SEARCH
| Key | Action |
|---|---|
| j/k | Navigate results |
| gg / G | Jump to top / bottom |
| d/u | Half-page down / up |
| Enter | Open in the mail list |
| e | Open read-only in $EDITOR |
| y | Copy the Markdown rendition path |
| f | Fetch a server-only hit into the store |
| r / R | Reply / Reply-all |
| w | Forward |
| a | Archive |
| b | Open HTML in browser |
| o | Open attachment |
| O | Save attachment to disk |
| Tab | Switch focus |
| Esc | Close overlay |
CONTACTS
| Key | Action |
|---|---|
| j/k | Navigate contacts |
| gg / G | Jump to top / bottom |
| / | Fuzzy search |
| Enter / n | Compose to contact |
| v | Send contact as vCard |
| c | Copy email address |
| r | Refresh contact index |
CALENDAR
| Key | Action |
|---|---|
| j/k | Navigate events |
| gg / G | Jump to top / bottom |
| Enter / e | Open the invite email in $EDITOR |
| V | RSVP to invitation (Accept/Tentative/Decline) |
| t | Show past events / upcoming only |
| r | Refresh events from disk |
HEADERS
| Key | Action |
|---|---|
| j/k | Scroll headers |
BODY
| Key | Action |
|---|---|
| j/k | Scroll line by line |
| Ctrl+d / Ctrl+u | Half-page down / up |
ACTIVITY LOG
| Key | Action |
|---|---|
| j/k | Scroll line by line |
| d/u | Half-page down / up |
| gg / G | Jump to top / bottom |
| / | Filter entries |
| Esc | Close overlay |