A CLI tool and TUI for managing emails from Markdown drafts with YAML frontmatter. Supports IMAP fetch/sync/archive and an auditable draft workflow (draft -> approved -> sent). Running mp with no arguments launches an interactive terminal UI.
# Build and install to ~/.cargo/bin/
cargo install --path .Run the interactive setup wizard:
mp config initThis will:
- Prompt for SMTP and IMAP credentials
- Test the connections
- Discover server mailboxes and assign roles (inbox, archive, sent)
- Write the config to
~/.config/mailypoppins/config.toml - Store passwords securely in the OS keyring (macOS Keychain, Windows Credential Manager, or Linux Secret Service)
All configuration lives in ~/.config/mailypoppins/config.toml:
[email]
font_family = "Helvetica, Arial, sans-serif"
font_size = "16px"
include_signature = true
send_hold_secs = 20 # Undo-send window in the TUI (0 = hand off immediately)
[smtp]
host = "postout.lrz.de"
port = 587
username = "your-id"
default_from = "your.name@example.com"
[imap]
host = "imap.example.com" # Falls back to smtp.host if omitted
port = 993
username = "" # Falls back to smtp.username if omitted
[mailboxes.inbox]
server = "INBOX" # IMAP folder name; the local directory is
# always <data_dir>/accounts/<name>/inbox/
[mailboxes.archive]
server = "Archive"
[mailboxes.sent]
server = "Sent Items"
[[mailboxes.extra]] # Additional mailboxes to sync
server = "Projects"The file has no signature keys: each signature is a Markdown file in ~/.config/mailypoppins/signatures/, and the account's default is recorded in the app's own state file. See Signatures.
Passwords are never stored in the config file. They live in the OS keyring under keys smtp-password and imap-password. IMAP password falls back to SMTP password if not set separately.
All mail, drafts, contacts cache, OAuth2 tokens, and logs live under a single OS-conventional app data directory:
| OS | Path |
|---|---|
| macOS | ~/Library/Application Support/mailypoppins/ |
| Linux (incl. WSL) | $XDG_DATA_HOME/mailypoppins/ (def. ~/.local/share/mailypoppins/) |
Layout:
mailypoppins/
accounts/<name>/{inbox,archive,sent,drafts,<extra>}/ # mail tree
accounts/<name>/contacts-cache.json # contacts index
tokens/<name>.enc # OAuth2 tokens
state.json # app state (default signature per account)
logs/mailypoppins-YYYY-MM-DD.log
The location is not user-configurable per account. To make the mail tree visible inside an Obsidian vault (or any other directory), symlink accounts/<name>/ into the desired location. Override the root for tests / portable installs with MAILYPOPPINS_DATA_DIR=/some/path.
For Microsoft 365 accounts using OAuth2 (IMAP/SMTP) or Graph API:
- Register an app in Azure Entra ID with delegated permissions (
Mail.Read,Mail.ReadWrite,Mail.Send). - Add the account to
config.tomlwithauth_method = "oauth2"(IMAP/SMTP) orauth_method = "graph"(Graph API, for tenants that block IMAP/SMTP). - Run the device code flow:
mp config oauth2-login --account <name>Graph accounts require an [accounts.oauth2] section with client_id and tenant_id, and use Graph well-known folder names (inbox, archive, sentitems) instead of IMAP folder names.
mp config init # Interactive setup wizard
mp config show # Display config (passwords masked)
mp config set-password # Store SMTP or IMAP password in keyring
mp config oauth2-login # Run OAuth2 device code flow
mp config path # Print config file pathFiles use Markdown with YAML frontmatter:
---
to: recipient@example.com
cc: optional@example.com # Optional
bcc: hidden@example.com # Optional
subject: "Your subject line"
status: draft # draft | approved | sent
from: alternate@example.com # Optional, overrides default_from
reply_to: reply@example.com # Optional
attachments: # Optional
- /path/to/file.pdf
---
Email body in **Markdown** format.draft --[mark-approved]--> approved --[send]--> sent
Only approved emails can be sent. The send command rejects drafts that haven't been approved.
Inbox emails follow a separate flow: inbox -> archived (via the archive command).
The file is updated in-place with status: sent, sent_at timestamp, sent_via version, and message_id.
A send that reached every recipient and was recorded in the outbox then deletes the draft file: the copy that matters lives on the server, where the outbox appends it to Sent and sync reads it back into the local store.
A partial send, or one the outbox store could not record, keeps the file with status: sent, because it is the only local copy left.
Sending such a draft again means editing status: back to approved by hand, since send refuses anything that is not approved and mark-approved refuses anything already sent; trim the recipient lines first, because a re-send delivers to everyone the file still lists.
mp # Launch the interactive TUI-A, --account <name> Account to use (default: first in config; every account for
calendar rebuild, contacts rebuild and cutover)
-s, --signature <name> Use a specific signature
--no-signature Skip signature entirelymp <selector> # Preview a draft (dry-run)
mp new <name> # Create a new draft from template, print its selector
mp list [--status S] [--json] # List the account's drafts (draft|approved|sent)
mp validate [<selector>] # Validate one draft, or every draft of the account
mp mark-approved <selector> # Mark draft as approved
mp send <selector> [-y] # Send a single approved email
mp send-approved [--all-accounts] [-y] # Send every approved draft of the account
mp reply <selector> [--all] # Create a reply draft from a received email
mp forward <selector> # Create a forward draft from a received email
mp delete <drafts-selector> [--force] # Delete a draft (--force for an approved one)
mp delete --sent # Clear every sent draft of the accountA draft selector is mp://<account>/drafts/<id>, drafts/<id> or <id>; a received one is mp://<account>/<mailbox>/<message-id>, and --mailbox <name> picks the mailbox a short one resolves in.
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 # List available server mailboxes
mp archive <selector> # Archive a received email (server + local)
mp delete <selector> # Delete a received email (server + local)
mp search <query> # Search emails on the server (--local: the store)
mp open <selector> # Open an attachment in the default app
mp save <selector> [--output] # Save attachment(s) to a directory
mp show <selector> [--json] # Print one received message from the local store| Option | 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) |
| Option | Description |
|---|---|
-n, --limit <N> |
Max messages per mailbox (default: 50) |
--mailbox <name>... |
Mailboxes to sync (default: all configured) |
--reconcile |
Also detect server-side moves and deletes |
Sync is additive by default (new emails only, deduped by Message-ID). With --reconcile, it also detects emails moved between INBOX and Archive on the server, and removes locally deleted emails.
| Option | Description |
|---|---|
--mailbox <name> |
Mailbox to watch (default: INBOX) |
--timeout <seconds> |
Timeout in seconds |
Exit codes: 0 = mailbox changed, 1 = error, 2 = timed out.
A signature is one Markdown file in ~/.config/mailypoppins/signatures/.
The file name without its extension is the signature's name, so work.md is the signature work, and creating a signature is creating a file.
The per-account default is recorded in the app's own state file, <data_dir>/state.json.
The signature is spliced into the draft body when the draft is created: after the body for a new message, above the quoted content for a reply or forward. So it is visible and editable while you write, and the same text feeds both the plain-text and the HTML part of the sent mail.
Manage them from the TUI with cs, which lists the signatures with the default starred: Enter sets or clears the default, e opens the selected file in $EDITOR, n creates one, r renames, d deletes after a confirmation.
HTML in a signature file is converted to Markdown as it is read, keeping links and line breaks, so a signature exported from another mail client can be pasted in as-is.
An account with no default signature adds nothing.
Use -s <name> to pick a signature for one draft, or --no-signature to skip it.
Signatures used to live in config.toml under [accounts.signatures.*].
The first run after the upgrade copies those entries out to files, keeps the old default, and prints a notice; the tables are dead after that and can be deleted by hand, since mailypoppins never rewrites config.toml.
Reply and forward drafts keep a {{SIGNATURE}} placeholder between the reply area and the quoted text.
It no longer carries signature text; it marks where the send path splits the body to place the quoted original.
Running mp with no arguments opens a full-screen terminal interface (built on ratatui).
Layout: sidebar (mailbox list) | email list | headers + body preview. Adapts to terminal width.
Keys are nvim-style mnemonic families: a leader letter opens a which-key popup listing its continuations, and the second key runs the action.
c is compose, t is thread and attachments, f is find and filter, s is sync and settings, g is go-to, Space switches view.
Forget a chord and : or Ctrl+p opens a command palette over every runnable action by name.
| Key | Action |
|---|---|
j/k, arrows |
Navigate |
Enter/e |
Open in $EDITOR (received mail is read-only) |
cn |
New draft |
cs |
Manage signatures |
cr/ca/cf |
Reply / Reply all / Forward |
cA/cD |
Approve draft / back to draft (Drafts only) |
x/cX |
Approve and send / Send all approved |
a/d |
Archive / Delete (with confirmation) |
ta/to/ts |
Attach file to draft / Open attachment / Save attachment |
tt/tb |
Show conversation / Open HTML in browser |
ff/fm |
Search all mail / Filter the current list |
ss/sS |
Quick sync / Full sync |
ga/gm |
Switch account / Go to the sidebar |
1-9 |
Jump to mailbox |
: or Ctrl+p |
Command palette |
? |
Help |
q |
Quit |
The help overlay (?) and mp dump-keys print the full catalogue, both generated from the same table the TUI dispatches on.
A send from the TUI is held for email.send_hold_secs (default 20) before it reaches the transport: the status line counts down and u cancels it, leaving the approved draft in place.
Set it to 0 to hand off immediately. mp send and the batch run are never held.
The TUI calls library functions directly (no subprocess spawning). Background IMAP IDLE watches for new mail and triggers automatic fetches.
# 1. Set up (once)
mp config init
# 2. Launch the TUI for interactive management
mp
# Or use CLI commands directly:
# 3. Sync inbox
mp sync
# 4. Create and review drafts
mp new meeting-followup # prints mp://<account>/drafts/<id>
mp list
mp mp://work/drafts/<id> # preview
# 5. Approve and send
mp mark-approved mp://work/drafts/<id>
mp send mp://work/drafts/<id>
# 6. Or batch send all approved
mp send-approvedRun mp config init to create the config file.
Run mp config set-password to store your password.
Run mp mark-approved <selector> first.
Check your credentials with mp config show and re-run mp config set-password smtp if needed.
Check that the account has a default: mp config show prints the signatures directory, the default, and every signature file it found. cs in the TUI sets the default.
The startup notice for the one-time move out of config.toml. Your signatures were already copied to ~/.config/mailypoppins/signatures/; delete the [accounts.signatures] tables from config.toml to silence it.
cargo build --release # Build
cargo install --path . # Install to ~/.cargo/bin/