Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

128 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

fem — CLI-based email management

Rust

Pull emails from IMAP4 servers and manage them locally with simple CLI commands. No daemon, no GUI — just you and your terminal.

🚀 Commands

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

📥 Pull

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)

📤 Push

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)

📋 List

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

🔍 Show

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/plainfem cat, text/htmllynx -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.

📖 Cat

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

✍️ Compose

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

🔎 Find

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

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)

🔐 PGP/MIME handlers

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"

🧬 PQP/MIME handlers

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'

⚙️ Configuration

Config file (TOML, checked in order):

  1. $XDG_CONFIG_HOME/fem/fem.toml
  2. $HOME/.fem/fem.toml

Data directory (Maildir storage):

  1. $XDG_DATA_HOME/fem/
  2. $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.

Example config

[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"

📁 Maildir Format

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.

🔧 Build

make build
make check
make test
make doc

Requires a Rust 2021 toolchain. Shell completions for bash, zsh, and fish live in completions/; a man page is available at man/fem.1.

📜 License

MIT. See COPYING for the full license text.

About

fem — CLI-based email management

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages