Pull emails from IMAP4 servers and manage them locally with simple CLI commands. No daemon, no GUI — just you and your terminal.
| Command | Description |
|---|---|
fem pull |
Connect to an IMAP4 server, fetch unseen emails, store in Maildir format |
fem push |
Send an email over SMTP (from stdin or from drafts) |
fem list |
Read local Maildir, print a formatted email table |
fem show |
Print a single email, with MIME processing and multipart handlers |
fem cat |
Read stdin → write stdout (rendering pipeline for fem show) |
fem compose |
Create a new email draft with $EDITOR |
fem find |
Search local Maildir by header, body text, or date range |
fem tag |
Tag an email (by its ID) with a label |
Fetch unseen emails from an IMAP4 server:
fem pull --server imap.example.com --username user@example.com
If --password is omitted, you'll be prompted securely. Fetch the whole
mailbox and skip already-fetched messages with --all:
fem pull --server imap.example.com --username user@example.com --all
Read the password from a command (e.g. your password manager):
fem pull --server imap.example.com --username user@example.com \
--password-command "pass email/imap"
Run a hook after each stored message (e.g. to notify a notifier daemon or
feed a search index). The message path is passed as $1; a non-zero exit
stops the pull:
fem pull --server imap.example.com --username user@example.com \
--post-hook ~/bin/mail-notify.sh
Transform messages before storing them with a pre-hook. The raw message is fed to the hook on stdin, and its stdout becomes the final message stored; empty output skips the message (a notice is printed, the pull continues), while a non-zero exit stops the pull:
fem pull --server imap.example.com --username user@example.com \
--pre-hook ~/bin/mail-sanitize.sh
| Option | Description |
|---|---|
-s, --server HOST |
IMAP server hostname (required) |
-p, --port PORT |
IMAP server port (default: 993) |
-u, --username USER |
Username for authentication (required) |
-w, --password PASS |
Password (prompts if omitted) |
--password-command CMD |
Run command, use first line of stdout as password |
-m, --mailbox MAILBOX |
Mailbox to fetch (default: INBOX) |
--ssl |
Use SSL/TLS (default: true) |
--all |
Fetch all emails, not just unseen ones |
--pre-hook BINARY |
Run before each stored message: stdin → stdout transform; empty output skips the message, non-zero exit stops the pull |
--post-hook BINARY |
Run after each stored message (receives the path as $1; non-zero exit stops the pull) |
Send an email over SMTP. Reads a raw message from stdin:
cat message.eml | fem push --server smtp.example.com --username user@example.com
Or send the drafts saved by fem compose — --all-drafts sends every draft,
--last-draft sends only the most recently modified one (both skip stdin):
fem push --server smtp.example.com --username user@example.com --all-drafts
Servers that use STARTTLS (typical for port 587) work too:
fem push --server smtp.example.com --username user@example.com --starttls
The envelope (sender and recipients) is taken from the message's
From/To/Cc/Bcc headers. A Bcc header stays in the envelope so hidden
recipients receive the message, but is stripped from the transmitted body so
recipients can't see each other. Drafts the server accepts are removed from the
drafts directory; drafts that fail to send are left in place.
| Option | Description |
|---|---|
-s, --server HOST |
SMTP server hostname (required) |
-p, --port PORT |
SMTP server port (default: 465 with SSL, 587 with STARTTLS, 25 without) |
-u, --username USER |
Username for SMTP authentication (required) |
-w, --password PASS |
Password (prompts if omitted) |
--password-command CMD |
Run command, use first line of stdout as password |
--ssl |
Use implicit SSL/TLS (default: true) |
--starttls |
Upgrade a plaintext connection with STARTTLS (mutually exclusive with --ssl) |
--drafts-path DIR |
Draft directory (default: <data-dir>/drafts) |
--all-drafts |
Send every draft in the drafts directory (mutually exclusive with --last-draft) |
--last-draft |
Send only the most recently modified draft (mutually exclusive with --all-drafts) |
Read the local Maildir and print a formatted table:
fem list
Expected output:
# Flgs Date From Subject
1 N A S T 2026-07-29 10:00:00 Alice <alice@example.com> Meeting tomorrow
2 R 2026-07-29 11:30:00 Bob <bob@example.com> Re: Project update
Flags: N new/unread, A attachment, E encrypted, S PGP/MIME signed,
R reply, T tagged with fem tag (all customizable with --flag-*).
Pagination, sorting, and custom columns:
fem list --limit 50 --offset 25 --order-by from:asc --table-fields "number,from,subject"
| Option | Description |
|---|---|
-d, --maildir-path DIR |
Path to Maildir (defaults to XDG data dir) |
--limit N |
Maximum rows (default: 25) |
--offset N |
Rows to skip (default: 0) |
--format FMT |
table (default), table-raw, json, tsv |
--table-fields FIELDS |
Columns, e.g. number,flags,date,from,subject (with :width) |
--order-by FIELD:DIR |
Sort (default: date:desc) |
--flag-new, --flag-attachment, --flag-encrypted, --flag-signed, --flag-reply, --flag-tagged |
Flag characters (defaults: N A E S R T) |
--date-format FMT |
chrono strftime format |
--no-thread |
Disable reply threading |
Print a single email (ID as shown in fem list):
fem show 3
Multipart emails are rendered by piping each part through a handler
(text/plain → fem cat, text/html → lynx -dump). Custom handlers:
fem show 176 --multipart-handle 'multipart/signed=contrib/pgp-signed-handler.sh'
| Option | Description |
|---|---|
ID |
Email ID (1-based, as shown in fem list) |
-d, --maildir-path DIR |
Path to Maildir |
--headers-show LIST / --headers-hide LIST |
Which headers to display (mutually exclusive; a CLI value wins over the config value of the other) |
--multipart-handle TYPE=CMD |
Handler for a content-type (repeatable) |
--multipart-preference PREF,FB |
Preferred type over fallback (default: text/plain,text/html) |
--no-multipart-preference |
Show all parts |
--raw |
Print the raw email without any processing |
--wrap WIDTH |
Wrap long lines (default: 100, 0 disables) |
--order-by, --no-thread, --flag-*, --date-format |
Same as fem list |
--no-color, --force-color |
Control colored output |
--color-header-key, --color-header-value, --color-part-separator, --color-handler-info, --color-handler-error |
256-color ANSI customization |
Handlers can write status to file descriptor 3 (shown green) and errors to stderr (shown bright red). See the PGP/MIME handlers and PQP/MIME handlers below.
Read stdin, write stdout — the rendering pipeline used by fem show:
echo "Hello world" | fem cat
| Option | Description |
|---|---|
--hide-signature |
Strip the signature (from -- delimiter) |
--collapse-citation |
Collapse quoted > ... blocks into > [previous message] |
--color-regex SPEC PATTERN |
Highlight lines matching a regex (repeatable) |
--no-color, --force-color |
Control colored output |
Create a new email draft. Prompts for recipient, sender, and subject
(unless provided), opens $EDITOR, and saves the draft:
fem compose --to user@example.com --from me@example.com --subject "Hello"
When prompted for To or From, press TAB to autocomplete addresses from
your maildir. The contact list is built from the From/To/Cc headers of
every stored message (cached to keep startup fast) and completed as
"Name" <email> — keep pressing TAB to cycle through the matching contacts:
$ fem compose
To: pepe<TAB> # → "Pepe Poco" <pepepoco@gmail.com>
To: pepe<TAB><TAB> # → "Pepe Ruiz" <peperuiz@example.com>
Start from an EML template — its To/From/Subject headers are replaced
with your values and a fresh Message-ID is added, while other headers are
kept. Exiting the editor without changes aborts (no hooks run, no draft saved):
fem compose --template reply.eml --to user@example.com
Or skip the editor entirely with --message: the automatic
To/From/Subject/Date/Message-ID override same-named headers in the
message, while other headers (Cc, Reply-To, ...) are kept:
fem compose --message "To: user@example.com
Cc: other@example.com
Hello!"
| Option | Description |
|---|---|
-d, --maildir-path DIR |
Maildir for To/From TAB-autocomplete contacts (default: <data-dir>) |
-t, --to ADDR |
Recipient (prompts if omitted) |
-f, --from ADDR |
Sender (prompts if omitted) |
-s, --subject TEXT |
Subject (prompts if omitted) |
--drafts-path DIR |
Draft directory (default: <data-dir>/drafts) |
--no-include-headers |
Hide headers from the editor; they are added on save |
--recover-last-draft |
Continue editing the most recent draft |
--pre-compose-hook BINARY |
Run before composing (receives temp EML path, repeatable) |
--post-compose-hook BINARY |
Run after editing, before saving (repeatable) |
--template FILE |
Use an EML file as the initial draft |
--message TEXT |
Full message content, no editor (mutually exclusive with --template; if a conflicting option is set in config, the CLI option wins and the config value is ignored) |
Encrypt your draft with PGP/MIME via a post-compose hook:
fem compose --post-compose-hook contrib/pgp-encrypt.sh
Or sign it — the sender's key is picked by the email address in the From:
header (a display name like "Name" <email> is handled too):
fem compose --post-compose-hook contrib/pgp-sign.sh
Search the local Maildir with regular expressions — by header, body text, or date range. All criteria are AND-ed: an email matches only when it satisfies every constraint.
Search the whole message (headers + body) for a pattern:
fem find "invoice"
Match a specific header, repeatably, and restrict to a date range:
fem find --header "Subject=^Re:" --header "From=@example\.com" \
--since 2026-01-01 --until 2026-06-30
Search only the decoded body text:
fem find --body "quarterly report"
Limit the search to a single folder (a maildir under the data directory):
fem find --folder drafts "draft"
Or narrow it to emails tagged with a label by fem tag:
fem find --label important
| Option | Description |
|---|---|
PATTERN |
Regex matched against the whole raw message (headers + body) |
-d, --maildir-path DIR |
Path to Maildir root |
-F, --folder NAME |
Sub-folder to search (absolute paths used as-is) |
-H, --header NAME=REGEX |
Match a header value with a regex (repeatable) |
-b, --body REGEX |
Match the decoded text body (base64/charset decoded) |
--since DATE |
Only emails on/after DATE (inclusive; date-only = start of day) |
--until DATE |
Only emails on/before DATE (inclusive; date-only = end of day) |
--limit N, --offset N |
Pagination (default limit: 25) |
--format FMT |
table (default), json, table-raw, tsv |
--table-fields, --flag-*, --date-format, --order-by, --no-thread |
Same as fem list |
--label LABEL |
Only match emails carrying this label (as set by fem tag) |
Regexes are Rust regex patterns and case-sensitive by default; use (?i) for
case-insensitive matching. The scan is parallelized across CPU cores and each
message is read and parsed exactly once. Header/date-only searches read just the
header block of each message instead of the whole file, and body decoding only
happens when --body is used — so header/date searches stay fast even on large
mailboxes. With --no-thread, only the best offset + limit matches are
retained, so memory and sort cost scale with the page size rather than the
number of matches.
Tag an email with a label by its ID (as displayed in fem list). The label
is required:
fem tag --label important 311
The ID is resolved with the same ordering and threading rules as
fem list/fem show (use --order-by/--no-thread to match a custom
listing). Labels are stored in a tags file at the Maildir root, and tagged
messages can be searched with fem find --label:
fem find --label important
Re-tagging a message with the same label is a no-op.
| Option | Description |
|---|---|
ID |
Email ID (1-based, as shown in fem list) |
--label LABEL |
Label to attach to the email (required) |
-d, --maildir-path DIR |
Path to Maildir root |
--order-by ORDER, --no-thread |
Same as fem list (used to resolve the ID) |
Ready-to-use scripts in contrib/:
| Script | Purpose |
|---|---|
pgp-handler.sh |
Decrypt multipart/encrypted emails (gpg --decrypt) |
pgp-signed-handler.sh |
Verify multipart/signed emails (gpg --verify), content always passed through |
pgp-encrypt.sh |
Compose --post-compose-hook: encrypts a draft as multipart/encrypted (RFC 3156) |
pgp-sign.sh |
Compose --post-compose-hook: signs a draft as multipart/signed (RFC 3156) with the From: sender's key |
Wire them up in your config:
[show.multipart_handle]
"multipart/signed" = "contrib/pgp-signed-handler.sh"
"multipart/encrypted" = "contrib/pgp-handler.sh"Post-quantum equivalents using the pqp tool (pqp gen-key -u you@example.com --no-passphrase; pqp must be on PATH). Keys must use --no-passphrase
because pqp cannot prompt without a TTY — the scripts pass --no-passphrase
to pqp decrypt/pqp sign.
| Script | Purpose |
|---|---|
pqp-handler.sh |
Decrypt multipart/encrypted emails (pqp decrypt --no-passphrase) |
pqp-signed-handler.sh |
Verify multipart/signed emails (pqp verify), content always passed through |
pqp-encrypt.sh |
Compose --post-compose-hook: encrypts a draft as PQP/MIME (pqp encrypt) |
pqp-sign.sh |
Compose --post-compose-hook: signs a draft as PQP/MIME (pqp sign) |
[show.multipart_handle]
"multipart/signed" = "contrib/pqp-signed-handler.sh"
"multipart/encrypted" = "contrib/pqp-handler.sh"# sign → verify
fem compose --from alice@example.com --post-compose-hook contrib/pqp-sign.sh
fem show 176 --multipart-handle 'multipart/signed=contrib/pqp-signed-handler.sh'
# encrypt → decrypt
fem compose --to bob@example.com --post-compose-hook contrib/pqp-encrypt.sh
fem show 176 --multipart-handle 'multipart/encrypted=contrib/pqp-handler.sh'Config file (TOML, checked in order):
$XDG_CONFIG_HOME/fem/fem.toml$HOME/.fem/fem.toml
Data directory (Maildir storage):
$XDG_DATA_HOME/fem/$HOME/.fem/
Every config key maps to a CLI flag — CLI overrides config. Each subcommand
has its own [section] ([pull], [push], [list], [show], [cat], [compose], [find], [tag]).
A fully commented sample lives at config_sample.toml.
[pull]
server = "imap.example.com"
port = 993
username = "user@example.com"
mailbox = "INBOX"
password_command = "pass email/imap"
[push]
server = "smtp.example.com"
port = 587
username = "user@example.com"
password_command = "pass email/smtp"
[list]
limit = 50
order_by = "date:desc"
[show]
wrap = 120
multipart_handle = { multipart/signed = "contrib/pgp-signed-handler.sh" }
[compose]
from = "me@example.com"
maildir_path = "/home/user/.local/share/fem"
post_compose_hook = ["contrib/pgp-encrypt.sh"]
[find]
label = "important"
[tag]
label = "important"Messages are stored in standard Maildir layout:
<data-dir>/fem/
├── cur/ # read messages
├── new/ # unseen messages
└── tmp/ # temporary files
Each file contains the full RFC822 email message.
make build
make check
make test
make docRequires a Rust 2021 toolchain. Shell completions for bash, zsh, and fish live
in completions/; a man page is available at man/fem.1.
MIT. See COPYING for the full license text.