mailplate is a Unix‑style filter for email drafts. It can integrate with a mail user agent, such as Mutt, to provide rule-based and manual control over your email identity, i.e. the signature to use in a given context, which headers to set, and how to greet your reader.
It reads a draft from standard input, rewrites it according to a (manually or
automatically selected) Profile, and writes the result to standard output
(or to a file with --output).
I am
working on publishing mailplate on Pypi.
Until that's successful, please run it from a checkout:
git clone https://github.com/madduck/mailplate
cd mailplate
uv sync
uv run mailplate --helpor install it (to ~/.local/bin by default) as follows:
git clone https://github.com/madduck/mailplate
cd mailplate
uv tool install .In Examples.md, you may find a couple of examples for your use and idea generation.
While mailplate may be useful as a CLI by itself (see "Usage as CLI" below),
it was primarly designed to integrate with the Mutt MUA, and
its descendents, such as Neomutt.
If your editor is "Vim", then the following integrates mailplate with your
MUA:
set edit_headers=yes
set editor="vim -c '%!mailplate --quiet'"
If you use vi, replace with vi; If you use Neovim, replace with nvim.
You need $edit_headers so that Mutt makes an email draft available; Otherwise
its just a text file, without access to header information.
In the above, Vim will be spawned and the buffer run through mailplate,
causing mailplate to analyse the draft, and rewrite it according to the
profile rules.
If you want to be able to change profiles from within Vim (and descendants), you
could provide key maps to similarly invoke mailplate --profile:
Here is some Lua inspiration for Neovim, to put into after/ftplugin/mail.lua:
vim.keymap.set("n", "<leader>p", "",
{ buffer = true, desc = "mailplate profiles" }
)
vim.keymap.set(
"n",
"<leader>pp",
":w<CR>:%!mailplate<CR>",
{ buffer = true, desc = "Automatically determine mailplate profile" }
)
local function profile_keymap(key, profile)
vim.keymap.set(
"n",
"<leader>p<" .. key .. ">",
":w<CR>:%!mailplate --profile " .. profile .. "<CR>",
{ buffer = true, desc = "Switch to mailplate profile '" .. profile .. "'" }
)
end
profile_keymap("F1", "private")
profile_keymap("F2", "work")
profile_keymap("F3", "club")and how if you press <leader>p<F3>, the mail draft is changed to the Profile
you use when doing work for your club.
mailplate --profile work < draft.eml > rewritten.emlProfiles discovered in subdirectories are addressed by their path relative to
the scanned directory, for example --profile work/foo for work/foo.mplt.
Omit --profile. mailplate scans available profiles, applies each profile’s
rules against the input message, and picks the profile with the most matching
rules. Profiles with no rules act as catch‑alls (score 0, selected only if no
rule‑based profile matches).
mailplate < draft.eml > rewritten.emlAn empty standard input is not an error. mailplate then starts from a minimal
RFC 5322 message (a single Content-Type: text/plain header) and applies the
selected profile to it:
printf '' | mailplate --profile workIf no profile is selected in that case, the minimal message is written unchanged.
mailplate --list-profilesDiagnostics are written to standard error:
-v/--verboseprints progress at INFO level.-vvor--debugprints per‑rule and capture detail at DEBUG level.-q/--quietprints only errors, hiding warnings.
mailplate -vv < draft.emlDEBUG output includes the profile search paths, discovery counts, per‑rule match/fail for every candidate, captured text, the selection rationale, and byte counts for reads and writes.
mailplate --output draft.eml < draft.emlProfiles are flat text files with a .mplt extension, stored in
$XDG_CONFIG_HOME/mailplate/profiles/ or /etc/mailplate/profiles/ (extra
directories can be added with --profile-dir). When $XDG_CONFIG_HOME is not
set, mailplate falls back to the XDG default configuration directory
~/.config. Profile directories are scanned recursively, so profiles may also
live in subdirectories and are treated identically wherever they are found.
Settings shared between profiles can be placed in a file named INCLUDE in any
profile directory (see below).
A profile is organised into sections:
[profile]
name = Work
[rules]
# Every matching rule adds its weight to the profile's score.
From = .*@company\.com
body = \[internal\]
[headers]
X-Mailer = mailplate/1.0
[greeting]
Hi {{ to_name | default("there") }},
[closing]
Best regards,
{{ from_name }}
[signature]
{{ from_name }}
{{ from_email }}
My Company Inc.mailplate only handles plain‑text drafts — messages that contain anything
other than text/plain (e.g. HTML parts, attachments) are rejected with an
error. The output is a bare RFC 5322 message without MIME headers (no
MIME-Version, Content-Type, or Content-Transfer-Encoding). Non‑ASCII
characters in header bodies are written verbatim as UTF‑8; mailplate does not
RFC 2047‑encode them, as the output is expected to be further processed by a
MUA, such as Mutt.
The output always starts with these headers, in this order:
From:
To:
Cc:
Bcc:
Reply-To:
Subject:
Message-ID:
Any of them that is set neither by the input nor by the profile is still
written, with an empty value (note the space after the colon: Cc: ). This
keeps the line numbers of a draft stable, which is what editors rely on when
they place the cursor or apply mailplate from within a :%! filter.
All other headers follow in the order of the input, with headers added by the
profile at the end. Removing one of the seven headers above with
[remove_headers] drops its value, but the header is still written empty.
Profile metadata. The only field is name. If absent the filename stem is used.
Auto‑detection rules. Each line is HeaderName = regex for a header rule or
body = regex for the message body. Each rule is matched independently: every
matching rule adds its weight to the profile's score, while a non-matching rule
adds nothing. The profile with the highest total score wins, provided that score
is at least 1. A rule can carry an optional weight by suffixing the field name
with :weight; the weight influences profile scoring and defaults to 1. Field
names are matched case-insensitively. The field name body is reserved for the
body rule, and rcpt for a rule matching against all of the To/Cc/Bcc headers
at once (it applies if the regex matches any one of them). The old
header = HeaderName: regex form (and its weighted variant) is no longer
accepted.
For example:
From:5 = .*@company\.com— a header rule worth 5 points instead of the default 1.body:3 = urgent— a body rule worth 3 points.X-Spam-Flag:0 = (?P<spam>YES)— a rule worth 0 points. It does not affect the score, but its named captures are still available to the template.X-Spam-Flag:-100 = YES— a rule worth -100 points. A sufficiently negative weight stops the profile from being selected whenever the rule matches.
Use positive weights to make some rules more important than others during auto-detection, a weight of 0 to collect captures without affecting the score, and a negative weight to disqualify a profile when a rule matches.
Named capture groups in a rule’s regexp become template variables under the
rule: namespace. For example:
[rules]
From = .*@(?P<domain>[a-z0-9.]+)makes {{ rule:domain }} available in greeting, closing, signature, and header
values. Rules are evaluated for named captures both during auto-detection and
when a profile is selected explicitly with --profile.
Header definitions. Each line is Header‑Name = value. Existing headers with
the same name (case‑insensitive) are replaced. Template placeholders (see below)
are expanded. The seven headers listed under “Header order” above always take
their canonical position and spelling, whatever spelling the profile uses.
Header values can reference other headers via {{ header:<Name> }} and the
{{ subject }}, {{ from }}, etc. shorthand variables.
Lines inserted before the original body. A blank line between sections is provided automatically. Template variables are expanded.
Lines inserted after the original body but before the signature. Template variables are expanded.
Lines appended at the end of the message. Template variables are expanded. The
signature is separated from the body by a blank line followed by a ^-- $
delimiter line. If the body already ends with a blank line, no extra blank line
is added.
The message body always keeps at least one line, so that it is framed by a blank
line above and below. An empty draft gains that blank line, and a draft with a
signature gains a second one before the -- delimiter. The last line of the
output always ends with a newline, so the draft is ready for more typing.
The lines of the [signature] section are copied verbatim: leading whitespace
and blank lines are preserved exactly as written in the profile.
If the original draft already contains a signature (i.e. text following a
^-- $ line), that signature is replaced by the profile’s [signature]
section. If the profile does not define a [signature] section, the original
draft signature is preserved unchanged.
Greeting and closing idempotency (see below) applies only to the body portion; the signature is never touched by that logic.
One header name per line; matching headers (case‑insensitive) are deleted from
the output. Takes effect after [headers] have been set.
External command definitions. Each line is name = shell command. The command
is invoked with shlex.split and subprocess.run with a 5‑second timeout. Use
{{ helper:<name> }} in template sections to insert the command’s stdout:
[helpers]
get_quote = fortune -n2
get_long_date = date '+%d %B %Y'Comments start with # and blank lines are ignored, except inside
[signature], whose content is preserved verbatim.
A file named exactly INCLUDE (with no extension) in a profile directory
provides settings that are automatically applied to every profile in that
directory and its subdirectories. When foo/bar/prof.mplt is loaded, the
INCLUDE files are applied in order from least to most specific — the root-level
INCLUDE, then foo/INCLUDE, then foo/bar/INCLUDE — followed by the profile
itself. An INCLUDE file uses the same format as a profile, except that it must
not contain a [profile] section.
[rules], [headers], [helpers], and [remove_headers] accumulate across
all files; the more specific file wins on a name collision, and verbatim
duplicate rules are kept only once. [greeting], [closing], and [signature]
are single‑valued: the most specific file that defines the section wins. An
explicitly empty section (for example a [signature] header with no lines)
clears an inherited one.
INCLUDE files are not listed by --list-profiles and cannot be selected as a
profile.
If the body already starts with the rendered greeting and/or ends with the
rendered closing + signature block, those are stripped before the fresh
decorations are applied. Running mailplate multiple times with the same
profile therefore does not keep piling on extra greeting or closing lines.
{{ variable | default(fallback) }} — if variable is empty, use fallback
instead. A quoted fallback (default("there")) is a literal string; an
unquoted one (default(rule:domain)) is a variable reference.
Filters are applied from left to right, so several fallbacks can be chained:
{{ rule:pmndomain | default(rule:domain) | default("example.net") }}
default is the only filter mailplate provides. The template engine supports a
small subset of template syntax and is not a Jinja2 implementation.
$XDG_CONFIG_HOME/mailplate/profiles/**/*.mplt— user‑specific profiles (found recursively)./etc/mailplate/profiles/**/*.mplt— system‑wide profiles (found recursively).- Examples — some ideas for you.
The manual page is written in POD in mailplate.pod. uv build renders it to
mailplate.1 with pod2man and installs it under share/man/man1/ in the
wheel. The generated mailplate.1 is a build artefact and is not tracked by
Git.
To render and preview the man page without building the package:
pod2man --section=1 --center="User Commands" --release=mailplate \
--name=mailplate mailplate.pod | man -l -mailplate was born in the late 90s as a shell script, which I no longer have.
In 2007, I hacked up a Python script to do much of the same, as well as some of
the fancy other stuff, such as being able to use rule captures or command output
in template tags. This
spaghetti code has been rotting
on my personal Git server, while
the desire to rewrite it properly has been expressed in my To-do list for years.
Then, Carlo told me about Pi over
lunch one day, and a few hours later, a new mailplate was born and has entered
my daily workflow. It's my first vibe-coded project, and I welcome your
constructive feedback and criticism. I would like to believe that this is not
"slop" due to my experience in software engineering, and the ancestry of
mailplate.
If you have existing profiles from the spaghetti-coded version of mailplate, a migration script is provided:
uv run python3 scripts/migrate_templates.pyThis reads templates from ~/.mailplate/templates/ and the helper configuration
from ~/.mailplate/config and writes .mplt files to the project’s profiles/
directory. After migration, copy them to $XDG_CONFIG_HOME/mailplate/profiles/
or point --profile-dir at the output directory.
It does a pretty good job, but will likely fail on any rule that is more complext than a simple regexp.
Distributed under the terms of the MIT license.
© 199x–2026 martin f. krafft mailplate@pobox.madduck.net
