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.

bash
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)
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