Sanimail
Summary
| Latest Version | Unknown |
|---|---|
| License | Unknown |
| CI Status | Failing |
| Stars | 1 |
| Forks | 0 |
| Open Issues | 0 |
| Last Commit | 2026-08-10 |
| Downloads | 0 |
| Last Indexed | 2026-08-11 05:07 |
Installation
nimble install Sanimail
choosenim install Sanimail
git clone https://gitlab.com/grepular/sanimail
OS Compatibility
| Platform | Linux | macOS | Windows | FreeBSD | OpenBSD | NetBSD | Android | iOS | WASM | Embedded |
|---|---|---|---|---|---|---|---|---|---|---|
| Sanimail | ✓ | ✓ | ✓ | - | - | - | - | - | - | - |
Source
| Repository | https://gitlab.com/grepular/sanimail |
|---|---|
| Homepage | https://gitlab.com/grepular/sanimail |
| Registry Source | gitlab |
README
sanimail
⚠️ Pre-v1 stability warning: until this project reaches v1.0.0, the CLI flags, policy language, and behavior are liable to experience breaking changes between releases.
License & contributing. sanimail is free software licensed under the AGPL-3.0. The author reserves the right to also distribute it under a separate commercial license. Because of this, any contribution must be offered under terms that let the author relicense it under any license, including a proprietary/commercial one — see CONTRIBUTING.md. (Want a non-AGPL commercial license? Open an issue or get in touch.)
Sanitize, de-track, neutralize and encrypt your email at delivery time. Sanimail reads a raw email on stdin, rewrites it to protect your privacy and security, and writes the result to stdout:
sanimail [flags] < input.eml > output.eml
▶ Try it in your browser. The playground runs the browser-safe transforms — content-policy sanitization, minification, plain-text generation, URL de-tracking, and header rewriting — entirely client-side via WebAssembly. Paste a message, toggle features, and watch the sanitized result. (Crypto and remote-content inlining need a
gpg/gpgsmbinary or network, so they run only in the CLI.)
Its headline trick is inlining remote content: instead of letting your mail client fetch the sender's images when you open a message — the mechanism behind read receipts and tracking pixels — sanimail fetches them on your server at delivery time and embeds them directly in the message. Your client renders the mail fully offline and the sender learns nothing. On top of that it sanitizes hostile HTML, strips tracking from links, cleans up privacy-invading headers, and can encrypt or sign with PGP or S/MIME.
That one-line interface drops into any mail pipeline — a Dovecot Sieve
pipe, a Postfix/maildrop filter, a .forward/procmail recipe, an Exim
transport, or a shell script over an existing Maildir. It is fail-safe by
design: if anything goes wrong it passes the original message through
untouched rather than lose your mail (see
Malformed Email Handling).
What it does
- Kills tracking pixels. With
--remote-inline, sanimail fetches remote images on your server at delivery time and attaches them inline, so your mail client never phones home to the sender when you open a message. Invisible 1×1 beacons are detected and neutralized; dead links (404/410) are defused so your client stops re-fetching them. - Strips tracking from links.
--detrack-urlsremovesutm_*,gclid,fbclidand friends, and unwraps redirector links to their real destination — fully offline, driven by the ClearURLs catalog. - Sanitizes hostile HTML.
--policyruns HTML, SVG, CSS, calendar invites, and vCards through htmlpolicy, stripping scripts, active content, and dangerous URL schemes. Ships with ready-made presets —standard,minimal, and emulators for Gmail, Outlook, Yahoo, and Proton Mail. - Defangs dangerous filenames.
--defang-filenamesneutralizes HTML metacharacters in attachment filenames, closing the stored-XSS class where a webmail client renders an attacker-chosen filename into its UI without escaping. - Defangs calendar, contact & header fields.
--defang-fieldsbreaks HTML tags in calendar (iCal) and contact (vCard) text fields and in rendered header values (Subject, sender display names) — aDESCRIPTION, aNOTE, a Subject — for clients that render those as HTML, catching even tags hidden in aQUOTED-PRINTABLE/base64/RFC 2047 (incl. UTF-7) encoding. - Encrypts and signs. PGP/MIME and S/MIME (via GnuPG) for encrypt, sign, and decrypt — including the gateway flow that decrypts inbound PGP and re-encrypts as S/MIME for a client that only speaks one of them.
- Cleans up headers. Strip or disarm read-receipt, priority, and other privacy-invading headers; auto-disarm DKIM on modified mail; add your own.
- Optimises images and HTML. Downscale, recompress, de-animate, and
metadata-strip remote images; minify HTML/CSS/SVG; strip dark-mode styling;
generate a
text/plainalternative from HTML. - Hardened. Locks itself down with Landlock + seccomp before it reads a single byte of untrusted mail (see Hardening).
Quickstart
Install with Go, grab a release binary, or build from source:
go install gitlab.com/grepular/sanimail@latest # needs Go
# or
git clone https://gitlab.com/grepular/sanimail && cd sanimail && make build # -> bin/sanimail
Then pipe a message through it. A sensible everyday invocation — sanitize HTML
with the recommended standard allowlist, defeat tracking pixels, and clean
tracking links:
sanimail --policy standard --remote-inline --detrack-urls < in.eml > out.eml
standard is a comprehensive allowlist of safe tags/attributes/css which
renders the vast majority of email well. If you'd rather keep all markup
by default and just exclude a list of known "bad" tags/attributes/css
then swap in the lighter minimal blocklist:
sanimail --policy minimal --remote-inline --detrack-urls < in.eml > out.eml
Decrypt inbound PGP and re-encrypt as S/MIME (e.g. for Apple Mail on iOS):
sanimail --pgp-decrypt --smime-encrypt ~/certs/me.pem < in.eml > out.eml
To wire sanimail into a real mail server (Dovecot, Postfix, …), see docs/deployment.md.
Reference
| Section | What it covers |
|---|---|
| Malformed Email Handling | Fail-safe passthrough and quarantine tagging |
| Header Stripping and Disarming | Removing or defanging privacy-invading headers |
| Adding Headers | Stamping your own headers onto a message |
| Authentication | DKIM / Authentication-Results auto-disarm |
| Remote Content Inlining | --remote-inline, tracking-pixel defeat, SSRF guard |
| Image Processing | --remote-img-* resize / recompress / optimise |
| Content Policy | --policy, presets, custom policy files, linting |
| Stripping Parts | --strip-type, AMP handling |
| Filename Defanging | --defang-filenames, attachment-filename XSS |
| Field Defanging | --defang-fields, calendar/contact field XSS |
| Text/Plain Generation | --generate-plain |
| Minification / Dark Mode | --minify, --strip-dark-mode |
| URL De-tracking | --detrack-urls, ClearURLs |
| Nested & Structured Content | Calendar, vCard, and forwarded-message sanitization |
| Encryption & Signing | PGP and S/MIME encrypt, sign, decrypt; header protection |
| Logging · Hardening · Exit Codes | Operational detail |
In-depth docs live alongside the README: deployment · remote content · content policy · encryption · nested content · stripping parts
Malformed Email Handling
When sanimail encounters a malformed email or a MIME part it cannot parse,
it passes through the original content unchanged and prepends one or more
Sanimail-Error headers describing the problem. Delivery is never
blocked by a parse failure.
This also covers the case where a message mostly parses but enmime
reports a severe error — meaning a portion of the message was lost
during parsing (e.g. an undecodable base64 part). Because the parsed tree
can no longer faithfully reproduce the original, sanimail does not
sanitize or re-encode such a message; it passes the original bytes through
verbatim so nothing is lost. The trade-off is that a severe parse error
suppresses sanitization for the whole message — an attacker could pair
hostile HTML with a deliberately malformed part to skip --policy. To make
this visible even without a header-aware filter, sanimail also prefixes the
Subject with [Sanimail-Malformed-Mime] (adding a Subject if absent)
so a simple subject rule can quarantine these messages.
Mail filters can match on the Sanimail-Error header (or the tagged
Subject) to sort flagged messages into a Malformed or Untrusted folder
for manual review. For example, in Sieve:
require "fileinto";
if anyof (header :contains "Sanimail-Error" "",
header :contains "Subject" "[Sanimail-Malformed-Mime]") {
fileinto "Untrusted";
}
Operators should route flagged/tagged mail to a quarantine folder rather than the inbox, since such messages are delivered unsanitized. Malformed email should be rare.
The presence of a Sanimail-Error header always means sanimail could not
fully sanitize the message, so it is the right — and only — signal to
quarantine on. Everything else sanimail has to report about a message goes
under two separate, non-quarantine headers, so filtering on Sanimail-Error
never quarantines a fully sanitized message:
Sanimail-Info— forensic notes about actions on a message that was fully sanitized: a part stripped by a default strip or--strip-type, a part retyped totext/plain, a minifier failure (the content ships unminified but still sanitized), or content a policy sanitized down to nothing. Routine mail (e.g. every AMP-bearing newsletter, whose AMP part is stripped by default) carries these; do not quarantine on them.Sanimail-Inlined-Failed— routine--remote-inlinefetch failures (a tracking pixel that doesn't return an image, a 404, an SSRF block, …); see Remote Content Inlining.
Likewise, the [Sanimail-Malformed-Mime] Subject prefix only ever accompanies
a Sanimail-Error, never a Sanimail-Info or Sanimail-Inlined-Failed note.
Upgrading from sanimail ≤ 0.9: these two classes were previously mixed in a single
Sanimail-Warningheader, which no longer exists. Update any filter matchingSanimail-Warningto matchSanimail-Error, or it will silently stop quarantining unsanitized mail.
Automatic Message-Id
When an input email has no Message-Id header, sanimail generates one in
RFC 5322 format (<base64url@sanimail>) and adds it to the email. This
enables correlation between messages in a mailbox and sanimail's processing
logs (every log line includes a message_id field). Use
--no-add-message-id to disable this behavior.
Flags
General
--verbose Log processing steps to stderr
--log-format FORMAT Log format: text (default) or json
--no-add-message-id Don't add a generated Message-Id header
when one is missing
--help Show usage information
--version Print version and exit
Output
--output-pipe COMMAND Deliver the finished message to COMMAND's
stdin instead of writing it to stdout.
COMMAND is split with shell-style quoting
and executed directly (no shell). A non-zero
exit aborts delivery (exit 1) so an upstream
MTA re-queues; mail is never partially
delivered.
By default the sanitized message is written to stdout, to be piped onward
by the calling MTA/MDA. --output-pipe instead hands it directly to a
delivery command, e.g.:
sanimail --policy standard --output-pipe "doveadm save -u mike -m Sanitized" < in.eml
Because the OS sandbox blocks execve once it closes (see Hardening),
the command is spawned before sandboxing — so it may load its libraries
and open connections (sockets, the dovecot admin socket, etc.) freely. The
sandboxed pipeline then feeds it the finished message over a pipe. Delivery
is fail-closed: if any transform fails, or the command exits non-zero, the
command is killed without receiving a partial message and sanimail exits 1,
leaving the upstream queue to retry rather than dropping the mail.
Header Stripping and Disarming
Certain headers are abused by senders to invade your privacy (read
receipts) or take advantage of your email clients UI to gain an
advantage (Priority). You may want to strip these automatically, or
just disarm them. The following options are for doing that. Disarming
just means to prefix the header with Sanimail- so that you can still
view the source of the email to see that a sender used one of these
headers, without your email client actually performing any automated
actions based on them.
Any incoming Sanimail-* headers are silently stripped before other
processing to prevent spoofing of Sanimail-Error or
Sanimail-Disarmed-* headers. This also applies to the content
revealed by --pgp-decrypt/--smime-decrypt, since a forged header
could otherwise be smuggled past the strip inside the ciphertext. The
side effect is that re-processing sanimail's own output — e.g.
decrypting a message sanimail encrypted, to inspect what it did —
discards the forensic headers sanimail wrote on the first pass
(Sanimail-Inlined-From, Sanimail-Error, Sanimail-Info, ...); sanimail cannot
tell its own headers apart from forged ones. --keep-sanimail-headers
opts out of the strip for that workflow:
--keep-sanimail-headers Don't strip incoming Sanimail-* headers.
Trusts them, so only use this when
re-processing sanimail's own output —
never on a path handling arbitrary
inbound mail.
Strip is applied before disarm. When both target the same header, the strip removes it and the disarm becomes a no-op for that header.
--headers-strip PATTERN,... Remove headers matching comma-separated
patterns. Glob-style: * matches any
substring. Case-insensitive.
E.g. "X-Mailer,X-MS-*,*-Priority*"
--headers-read-receipts-strip
Remove Disposition-Notification-To and
similar headers
--headers-priority-strip Remove X-Priority, Importance, etc.
--headers-disarm PATTERN,...
Rename matching headers to
Sanimail-Disarmed-{Name}. Same glob
syntax as --headers-strip.
--headers-read-receipts-disarm
Rename receipt headers to
Sanimail-Disarmed-{Name}
--headers-priority-disarm Rename priority headers to
Sanimail-Disarmed-{Name}
Adding Headers
You can stamp arbitrary headers onto the message, for example to flag
it for a downstream filter or record that sanimail processed it. Both
flags take a Name: value argument, are repeatable, and run after
--headers-strip and --headers-disarm (so a value you add is not
itself stripped or disarmed). Values are RFC 2047-encoded and folded
automatically at serialization time; a value containing CR/LF is a
usage error.
--headers-set "Name: value" Add a header, replacing any existing
header(s) of the same name. Repeatable.
--headers-add "Name: value" Add a header, keeping any existing
header(s) of the same name. Repeatable.
Authentication
When sanimail modifies a message body, it automatically renames
DKIM-Signature and Authentication-Results headers to
Sanimail-Disarmed-* since body modifications invalidate DKIM
signatures and make Authentication-Results stale. ARC headers are
left intact.
--keep-signatures Don't auto-disarm DKIM-Signature and
Authentication-Results headers when
other flags modify the message.
Does not affect explicit --headers-strip
or --headers-disarm patterns targeting
these headers. Use this when sanimail
runs after a trusted MTA whose
Authentication-Results you need to
preserve.
Remote Content Inlining
Marketing email commonly embeds remote images so the sender can log
when and from where the recipient opens the message — the classic
"tracking pixel" pattern. --remote-inline defeats this by having sanimail
fetch the referenced assets at delivery time on the mail server,
attaching them as inline MIME parts with cid: references, so the
recipient's MUA never makes a network call when rendering the message.
--remote-inline Fetch remote images and fonts
referenced in HTML/SVG/CSS, attach
them as inline parts (cid: URIs), and
rewrite the references accordingly.
Requires network access.
--remote-timeout DURATION Per-fetch timeout. Default: 15s.
--remote-total-timeout DURATION
Aggregate wall-clock budget for all
fetches in one message. Default: 45s.
0 = unlimited. Bounds the delivery
stall a message of slow URLs can cause.
--remote-max-count N Max distinct URLs to fetch per message
(shared across HTML, SVG, and CSS
walks). Default: 42. 0 = unlimited.
--remote-max-bytes BYTES Total byte budget across all fetches
per message. Default: 16MiB. 0 =
unlimited. Accepts 16MiB, 500K, etc.
--remote-item-max-bytes BYTES Per-fetch byte cap. Default: 5MiB.
0 = unlimited.
--remote-max-parallel N Max concurrent fetches per message.
Default: 16. 0 = unlimited.
--remote-max-parallel-per-host N
Max concurrent fetches to one host,
like a browser's per-server connection
limit. Default: 6. 0 = unlimited.
Under HTTP/2 this caps concurrent
streams on the shared connection.
--remote-user-agent STRING User-Agent sent on inline fetches. A
bare bot token is often blocked by CDN/
WAF bot rules, so the default is browser-
shaped with a "(via sanimail/<version>)"
marker (like Gmail's image proxy).
Override if a host still blocks it.
--remote-fetch-proxy ROUTE Add a hop to an ordered failover chain
(repeatable). Each fetch tries the applicable
routes in order, advancing on a failure that
matches --remote-fetch-failover-on (and always
on an unreachable proxy). ROUTE is one of:
direct a no-proxy hop
socks5h://host:1080 a bare proxy URL,
no credentials (socks5/socks5h/http/https)
url=...,user=...,password=...,
password-file=...,scheme=...,host=...
the structured form (the value contains
'='). url= may be "direct"; credentials
come only from user=/password=/
password-file= (never embedded in the
URL), and password= and password-file=
are mutually exclusive;
scheme= (http|https) and host= (a glob)
are repeatable per-route conditions
limiting which targets the route serves
(e.g. scheme=https to keep plaintext http
off a Tor proxy). Conditions hold on every
redirect hop, not just the original URL:
a hop that violates them (e.g. an
https→http downgrade, or a bounce to a
host outside host=) aborts that route's
attempt and, under the default failover
set, retries the original URL on the next
applicable route — the chain never
switches proxy mid-flight. This applies to
direct routes too. password-file= reads the
secret from a file (trailing newline
trimmed), keeping it out of argv.
The fetch target's DNS resolves at the proxy
(remote DNS), so Tor and .onion work and no
target lookup leaks locally. The proxy endpoint
is trusted (exempt from the SSRF blocklist); a
literal private/reserved target IP is still
rejected, and an SSRF block or a redirect chain
past the browser-parity cap (20 hops) never
fails over.
--remote-fetch-failover-on LIST Which fetch failures advance to the next
route (default "blocked,transient"). LIST is a
comma list of: blocked
(403,451,notimage,condition), transient
(408,429,5xx,timeout,connect,tls,dns),
an explicit status (403), a range (4xx,5xx), a
transport keyword (timeout|connect|tls|dns),
notimage (a 2xx that isn't an image, e.g. a
challenge page), or condition (a redirect hop
violating the route's scheme=/host=
conditions). Prefix a token with '-' to
subtract it (e.g. blocked,transient,-tls).
--remote-fetch-try-https-upgrade Try each http:// resource (default port
only) over https first, falling back to the
original URL when the https variant fails —
any transport error, any non-2xx status, or a
2xx that isn't an image/font. Encrypts the
fetch when the server supports it without ever
losing a resource that only exists on http.
Combine with a scheme=https route condition to
refuse the plaintext fallback entirely.
--remote-debug-headers Emit the diagnostic Sanimail-Inlined-Debug
header (fetch/route timing, redirect target,
SVG/image transforms, de-animation, WASM
fallback, content-type override) and append
the route breakdown and fetch timing to
Sanimail-Inlined-Failed. Off by default;
Sanimail-Inlined-From stays on regardless.
--remote-img-max-width N Downscale fetched raster images
(JPEG/PNG/GIF) wider than N px,
preserving aspect ratio. Keeps the
original codec. 0 = no limit. An
animated GIF or PNG (APNG) is left
untouched unless --remote-img-deanimate
is also set (then the flattened still is
downscaled). There is deliberately no
max-height counterpart: mail readers
scroll vertically, and a height box
collapses tall receipt-style images
into unreadable slivers. RAM and byte
budgets bound resources instead.
--remote-img-deanimate Reduce a fetched animated GIF or PNG
(APNG) to its resting frame as a static
image. Also shrinks the attachment a lot
— only one frame is kept. Bounded by
--remote-img-deanimate-cap.
--remote-img-deanimate-cap D Wall-time cap on de-animating one image
(e.g. 500ms, 2s; 0 = unlimited).
On expiry the de-animation keeps the
frame composited so far rather than
failing — the image is still flattened,
just to an earlier frame. Default: 500ms.
--remote-img-jpeg-quality N Recompress fetched JPEGs at quality N
(1-100), kept only when the result is
smaller. N is also the quality used
whenever another flag re-encodes to JPEG.
0 (default) = no recompression; forced
re-encodes use a fixed quality of 80.
--remote-img-optimise Re-encode a fetched non-animated
PNG/GIF/WebP/BMP/TIFF to the smaller of
JPEG or lossless PNG when opaque (a sharp
graphic JPEG can't beat stays lossless),
or lossless PNG when transparent. Kept
only when smaller than the source.
--remote-img-max-ram SIZE Reject a fetched image whose approximate
peak decode RAM (canvas width×height×4×3,
doubled for a 16-bit PNG, read from the
header before decoding) exceeds this — a
decompression-bomb guard. The image is still
inlined (attached as-is, decode skipped) with a
skipped= token in Sanimail-Inlined-Debug
explaining why.
Default: 1GiB (≈89 MP canvas, 8-bit);
0 = unlimited. Accepts
raw bytes or units (e.g. 64MiB, 128MB).
Applies to every decodable fetched image
(JPEG/PNG/GIF/WebP/BMP/TIFF), with or
without a resize flag. Because de-animation
streams one frame at a time, this bounds a
single canvas, not frames×canvas — so a
long small-canvas animation stays cheap.
Note:
0means "unlimited" for each budget independently. Setting--remote-max-count 0and--remote-max-bytes 0together removes both the fetch-count and total-byte limits, leaving only the per-fetch caps — a footgun on untrusted mail. Keep at least one bounded.
What you get, in short:
- Tracking pixels are neutralized. An image that measures 1×1 is replaced by an inert reference instead of being attached, so it can never fire — not even server-side.
- SSRF is blocked, always-on. Fetches to private/loopback/reserved addresses are refused by a two-layer guard, re-checked on every redirect hop.
- Remote SVG is sanitized before attachment (some MUAs execute
scripts in SVG loaded via
<img src=cid:>). - Dead links (404/410) are defused so your client never re-fetches them or prompts to load remote content.
- Latency is bounded by
--remote-total-timeout; fetches run in parallel, capped per-host like a browser. - Tor / proxy routing is available via
--remote-fetch-proxy(remote DNS, so.onionworks). Per-routescheme=/host=conditions hold on every redirect hop, so e.g.scheme=httpson a Tor route can never be downgraded to plaintext http by a redirect. - A failed fetch never triggers quarantine. When a fetch is rejected or
fails — non-image content (a tracking beacon that returns
text/plain), a 404, an SSRF block, an oversize body, a decode panic, an unparseable URL — the message is still fully sanitized, so the event is recorded under a forensicSanimail-Inlined-Failedheader (naming the URL and reason) rather than a quarantineSanimail-Error. It is a member of theSanimail-Inlined-*family, so it is stripped on reprocessing. The failure is also counted in the per-message stats and, where the URL is neutralized, marked inert in the body (#sanimail-dead-resource#/#sanimail-blocked-resource#).
Only URLs a client auto-fetches on render are touched (images, CSS
url(...), @font-face) — links, form actions, and media sources are
left alone. Fetched parts are relabelled by content sniffing (not the
server's claim) and attached inside a multipart/related wrapper. The
--remote-img-* flags then post-process the fetched images (see
Image Processing).
Full mechanics — exact element/attribute scope, the SSRF guard, tracking-pixel and dead-link neutralization, compression handling, content-type sniffing, the MIME shape, and image processing — are in docs/remote-content.md.
Image Processing
The --remote-img-* flags post-process images fetched by --remote-inline
(they are no-ops without it) and apply only to inlined remote images —
already-attached images are the sender's deliberate payload and are left
untouched. They can downscale (--remote-img-max-width),
de-animate (--remote-img-deanimate), recompress JPEGs
(--remote-img-jpeg-quality), convert to the cheapest mail-safe format
(--remote-img-optimise), and guard against decompression bombs
(--remote-img-max-ram). A re-encode is kept only when it is
actually smaller, EXIF orientation is baked in before any JPEG re-encode so
photos never come out sideways, and processing is fail-safe — a decode/encode
error keeps the original bytes (still inlined) and adds a skipped= token to the
per-part Sanimail-Inlined-Debug header rather than dropping the image.
See docs/remote-content.md for the per-flag detail and the format-support matrix.
Content Policy
Sanimail uses htmlpolicy for HTML and SVG sanitization. A policy is a declarative text file that says which elements, attributes, URL schemes, and CSS properties to strip, allow, defang, or comment out.
--policy NAME-OR-PATH Sanitize text/html, image/svg+xml,
text/css, text/calendar, and text/vcard
parts. Accepts one of the
embedded preset names (standard, minimal,
gmail-web, outlook-com, yahoo-mail-web,
protonmail-web), the name of a .policy file
in the user config directory, or a path to
a .policy file. Names are resolved first
(embedded preset, then config dir); a value
containing a path separator is always
treated as a path.
Run sanimail policy presets to print the embedded preset names (and any
user presets) with a one-line summary of each, grouped by category.
Six presets ship embedded in the binary: two general-purpose security postures and four provider-emulation presets.
General-purpose:
- standard — Allowlist: drops everything, then allows back the HTML,
attributes, and CSS commonly used in email. Inline
styleand<style>blocks are kept but scrubbed against a CSS-property allowlist; unlike minimal,<style>selectors are additionally filtered to a static-only pseudo-class allowlist, so interaction-gated (:hover,:focus) remote-fetch beacons are dropped. Unknown tags are unwrapped (their text survives). The recommended default for most users — comprehensive, yet renders the vast majority of mail well. - minimal — Blocklist: passes everything through unchanged except
known-dangerous or pointless-in-email content. Removes scripts and
other active content, frames/objects/embeds, forms, remotely-fetching
media elements, all
<link>elements,<base>, meta refresh, event-handler attributes, theping/manifesttracking attributes, and sketchy CSS (position,@import,@font-face,expression(), IEbehavior/-moz-binding, and remote fetches viacontent/cursor/list-style-image). Keeps layout, inline styles,<style>blocks, background images, anddata:images; SVG/MathML are kept but their active content is stripped. The lightest-touch option.
Both presets allowlist URL schemes on HTML attributes and CSS
url(): http, https, mailto, tel, sms, callto, xmpp,
geo, cid, data, and relative URLs are permitted; everything else —
javascript:, vbscript:, file:, the Windows app-handler schemes
(ms-msdt:, shell:, …), and any unknown scheme — is removed.
Schemes are allowlisted (not blocklisted) because that threat space is
open-ended; the blocklist-vs-allowlist distinction between minimal and
standard is about which tags, attributes, and CSS properties each keeps.
Provider emulation — each preset emulates one major webmail client's
inbound HTML sanitiser, so the output matches what that client would
render. Renderer-layer behaviours (image-proxy rewrites, forced
target="_blank", class scoping) can't be replicated by an HTML filter;
see each preset file's header for known deviations.
- gmail-web — Gmail's desktop webmail. Enforces Google's published
CSS-property allowlist directly and strips Gmail's documented
unsupported elements (
<video>,<picture>, inline<svg>,<link>,<dialog>,<marquee>). - outlook-com — Outlook.com / Outlook on the web. Uses
unwrap-tag *to mimic stripping unsupported HTML5 semantic tags while keeping their content. Out of scope: desktop Outlook for Windows (Word engine). - yahoo-mail-web — Yahoo Mail's desktop webmail.
- protonmail-web — Proton Mail web; the highest-fidelity emulator
because Proton's sanitiser is GPL-3.0 and readable directly
(DOMPurify + Proton's
protonizerconfig).
Writing your own, the include system, the user policy directory
(~/.config/sanimail/policies/), sanimail policy lint, data-URI
handling, and how sanitization interacts with the sandbox are documented in
docs/content-policy.md.
Stripping Parts
--strip-type CONTENT-TYPE Remove MIME parts with this exact
content-type (repeatable)
--keep-amp Keep AMP (text/x-amp-html) parts instead
of stripping them
AMP for Email parts (text/x-amp-html) are stripped by default. AMP is a
separate, active-content document format that Gmail, Yahoo, and Mail.ru render
in preference to the ordinary text/html alternative; it carries its own
runtime script and can fetch remote data and update itself after delivery, so a
static HTML policy can't meaningfully sanitize it. The AMP spec requires every
AMP message to also include a text/html/text/plain fallback, so dropping the
AMP part loses no content — the client falls back to the ordinary HTML,
which --policy can sanitize. Pass --keep-amp to deliver it untouched.
--strip-type removes parts of any content-type (repeat it for several). Both
are audited (each removed part adds a non-quarantine
Sanimail-Info: stripped <type> part header), run before --policy so a
stripped part can never slip past
sanitization, and never empty a message — a matching part that is a container's
only content is kept.
Microsoft TNEF (winmail.dat) parts (application/ms-tnef,
application/vnd.ms-tnef) are passed through as opaque attachments — the
RTF/HTML body inside is not decoded or sanitized. This only matters for
recipients reading in Outlook/Exchange (other clients can't render TNEF at all),
and TNEF is a declining sender-side artifact. Strip it with --strip-type if you
prefer not to pass it through.
The AMP sole-content trade-off and the full TNEF rationale are in docs/stripping-parts.md.
Filename Defanging
--defang-filenames Replace unsafe characters in attachment
filenames with _
Off by default. When enabled, sanimail replaces the HTML-significant characters
< > " ' and any control characters in every attachment's filename with an
underscore. One value drives both the Content-Disposition filename= and the
Content-Type name=, so both are cleaned together. Spaces, &, =,
parentheses, dots, and all non-ASCII characters are left intact, so
<img src=x onerror=alert(1)>.pdf becomes _img src=x onerror=alert(1)_.pdf —
inert (no <, so no tag can form) but still recognizable.
This defends against a mail client that renders an attacker-chosen filename into
its web UI without escaping it — the stored-XSS class patched in several
webmail clients (e.g. Zimbra's Classic Web Client). The bug is the client's
missing output encoding, not the filename itself: a filename is valid data, so
neutralizing it is a belt-and-braces measure rather than a guaranteed-safe
default, which is why it is opt-in. Like the other part transforms it runs on the
top-level message and every nested message/rfc822 (forwarded) tree, and a
filename with no unsafe characters is left byte-for-byte untouched.
The set covers the element-content and quoted-attribute injection contexts (the
common ones). It deliberately keeps spaces and =, so it does not neutralize
a client that splices the filename into an unquoted HTML attribute — defending
that would mean mangling ordinary filenames. Fixing the output encoding in the
client is the only complete remedy; this option shrinks the attack surface, it
does not replace the client patch.
Field Defanging
--defang-fields GROUPS Neutralize HTML tags in rendered fields.
GROUPS is comma-separated:
calendar - iCal text properties
contacts - vCard text fields
headers - Subject, sender display names, …
all - every group
Off by default; independent of --policy. When enabled, sanimail breaks
recognized HTML tags in the selected fields by inserting _ after the < —
<script>alert(1)</script> becomes <_script>alert(1)<_/script>, which a
browser renders as inert text. This defends clients that render a calendar
DESCRIPTION, a vCard NOTE, or an attendee display name (ATTENDEE;CN=…) as
HTML without escaping it — the stored-XSS class behind Zimbra CVE-2025-27915 and
similar webmail flaws.
A tag is broken when any of these hold: its bare name is a recognized HTML
element (<script> is dangerous with no attributes at all); it contains
whitespace — a space-separated attribute, where an on* handler lives, on any
element including an unknown or custom one (<x-x onclick=…>); it contains a
quote (which would let <x-x/foo="a"onclick=b> start a handler with no
separator); or it carries a /-introduced event handler (<x-x/onclick=…>).
Anything else is inert and left untouched — a placeholder <foo>, an email
address or message-id in angle brackets (<bob@example.com>), and an
angle-bracketed URL (<https://host/path?id=1>, <mailto:a@b>). A browser
treats <https://host> as an element too, but with no handler it runs nothing,
so breaking it would only corrupt the URL — e.g. a List-Unsubscribe link — for
no gain; whitespace, a raw quote, and a /on…= handler never appear in a
well-formed URL. A field with no dangerous tag passes through byte-for-byte.
The scan is context-free, so it also neutralizes a <script> hidden inside a
RAWTEXT element such as <title> (which a stateful parser would expose once the
container tag is broken). It runs on the whole unfolded line, so a tag in a
parameter is caught too — and for vCard it decodes transfer-encoded values
(ENCODING=QUOTED-PRINTABLE/base64, any CHARSET including UTF-7, and values
split across QP soft line breaks) before scanning, so a tag hidden in the
encoding can't slip past. When a tag is found in an encoded vCard property, that
property is re-emitted as a plain UTF-8 value with its ENCODING/CHARSET
parameters removed; a value whose transfer encoding or charset can't be decoded
is likewise stripped to inert literal text (fail-closed), while a clean encoded
property is left as-is.
The headers group applies the same scan to the values of the headers a client
renders as HTML — Subject, Date, sender/recipient display names (the
addresses themselves are preserved), and any X-* — decoding RFC 2047
encoded-words first, so a tag hidden as =?UTF-8?B?…?= (or split across words,
or in UTF-7) is caught. Headers that are structural (Content-*),
cryptographic/authentication (DKIM-Signature, ARC-*, Authentication-Results),
sanimail-managed (Message-Id), or in the Sanimail-* namespace are left
untouched.
One limit worth naming: a field spliced into an unescaped HTML attribute
(title="…") can still break out via a quote (there's no tag to break — the same
residual --defang-filenames has). Like the other defang options this shrinks
attack surface; the complete fix is the client escaping its own output.
Text/Plain Generation
--generate-plain MODE Generate text/plain from HTML. MODE is
one of:
always - always replace existing
text/plain (same as 0)
missing - only add when no text/plain
exists or it is empty
words:N - replace when the original
has N or fewer words
<ratio> - replace when the generated
version has at least RATIO
times as many words as the
original. E.g. 2.0 replaces
a "View in browser" (3 words)
if the generated text has
6+ words. 0 = always replace.
Minification
--minify Minify text/html, text/css, and image/svg+xml parts
--minify shrinks text/html, text/css, and image/svg+xml parts by
stripping comments, redundant whitespace, and other removable bytes. CSS
embedded in HTML — both <style> blocks and style="" attributes — is minified
as part of the HTML pass, and standalone text/css parts (e.g. a cid:-linked
stylesheet) are minified too. image/svg+xml parts (whether native to the
message or inlined by --remote-inline) are compressed with a dedicated SVG
minifier. (Apple Watch's text/watch-html parts are minified as HTML.)
Minification works with or without --policy. When both are set, parts are
sanitized first and then minified, so minification always runs on the
already-sanitized output. Remote stylesheets are not fetched; only parts
already present in the message are minified.
Dark Mode
--strip-dark-mode Strip dark-mode CSS so light styling renders
--strip-dark-mode removes the mechanisms a message uses to render itself in
dark mode: @media (prefers-color-scheme: dark) overrides, the color-scheme
CSS property, and <meta name="color-scheme">. A page that styles itself light
by default and only overrides to dark is left rendering light; a page that is
hardcoded dark with no media query is not repainted (sanimail strips the
dark mechanism, not author-chosen colours). Responsive @media queries that
have nothing to do with colour scheme (e.g. max-width) are untouched.
It is layered on top of whatever policy is active, so it works with or without
--policy (like --minify and --detrack-urls, it is gated only on its own
flag).
This feature was added because I noticed that some incoming email had a dark mode in the html, but clearly nobody ever QA'd it in dark mode as the styling was attrocious.
Policy Audit Comment
--policy-audit-comment Prepend an HTML comment to each text/html
body listing what the policy stripped
--policy-audit-comment records every action the --policy sanitizer took —
stripped elements, removed attributes, rejected URL schemes, dropped CSS
properties — and prepends them as an HTML comment at the top of each
text/html body:
<!-- sanimail policy-audit:
strip <script>
strip-scheme <a> href javascript:alert(1)
-->
The comment is invisible in every real mail client but shows up in "View
Source", so you can audit after the fact what sanimail changed without
affecting how the message renders. It is injected after minification, so
--minify cannot strip or re-wrap it.
The flag requires --policy (it has nothing to report otherwise). When the
policy strips nothing, no comment is added. A message with no text/html part
has nowhere to carry the comment, so those actions go unrecorded. The listing
covers the HTML/CSS/SVG sanitizer only — not --detrack-urls or --remote-inline.
URL De-tracking
--detrack-urls Strip tracking parameters from links and
unwrap redirector links to their target
--clearurls-data PATH Use an alternative ClearURLs ruleset file
instead of the embedded snapshot
--detrack-urls removes tracking query parameters (e.g. utm_source, gclid,
fbclid) from links and unwraps redirector links to the destination embedded
in the URL itself (e.g. https://l.example/redir?u=https%3A%2F%2Freal%2F →
https://real/). It is driven by an embedded offline snapshot of the
ClearURLs rules catalog.
Only links are touched: <a href> attributes in HTML and bare http(s)
URLs in text/plain bodies. Image src, CSS url(), form actions, and every
other URL context pass through unchanged, so signed/CDN URLs are never broken.
De-tracking works with or without --policy.
De-tracking is entirely offline — it never makes a network request. A redirector is unwrapped only when its destination is encoded in the link; opaque-token click trackers, whose destination only the tracking server knows, are left intact. It is also fail-safe: a URL that can't be parsed or recognised is left exactly as-is, so a link is never broken.
Note: in addition to tracking parameters,
--detrack-urlsalso strips ClearURLs' referral-marketing parameters (affiliate tags). This is more aggressive than the ClearURLs browser extension, which leaves those in place unless the user opts in — sanimail always removes them.
text/plain parts marked RFC 3676 format=flowed are handled correctly: the
format=flowed (and delsp) parameters are preserved across de-tracking, and a
URL split across a soft line break under delsp=yes is reconstructed before
de-tracking so it is never corrupted. If de-tracking leaves such a URL long
enough to overflow a physical line, it is re-wrapped across soft breaks so the
output
never exceeds the RFC 5322 998-octet line limit. Soft-break spaces,
space-stuffing, and quoting in the rest of the body are preserved verbatim.
To run with newer rules than the embedded snapshot without rebuilding, fetch
the latest catalog and point --clearurls-data at it:
sanimail clearurls fetch -o clearurls.json
sanimail --detrack-urls --clearurls-data clearurls.json < in.eml > out.eml
clearurls fetch downloads and validates the catalog over the network
(outside the mail-processing sandbox); it never touches mail. To pin to the
catalog compiled into the binary instead, sanimail clearurls export -o
clearurls.json writes the embedded snapshot offline (no network).
Nested & Structured Content (calendar, vCard, forwarded mail)
Hostile HTML, tracking pixels, and dangerous-scheme links don't only live in the
top-level text/html body — they hide in places a naive sanitizer never looks.
Sanimail reaches into all of them, applying the same --policy,
--detrack-urls, and --remote-inline treatment with no extra flags:
- Calendar invites (
text/calendar) — the HTML descriptions Outlook renders inline (X-ALT-DESC, RFC 9073STYLED-DESCRIPTION) are run through the full HTML pipeline, and clickable/resource URLs are de-tracked and scheme-checked. - Contact cards (
text/vcard) — remotePHOTO/LOGOURLs (tracking pixels by another name) are de-tracked and, with--remote-inline, fetched and embedded in the card; inline SVG photos are sanitized; theURLhomepage link is scheme-checked. - Forwarded messages (
message/rfc822) — re-parsed and run through the entire content pipeline (several clients render these inline with the same HTML engine as the top-level body), sharing one fetch budget; recursion is depth-bounded and fail-safe.
Untouched content is preserved byte-for-byte. Full detail — exact properties, encodings, and bounds — is in docs/nested-content.md.
Encryption & Signing (PGP / S/MIME)
Sanimail can encrypt, sign, and decrypt with both PGP/MIME (RFC 3156, via
the system gpg) and S/MIME (CMS, via gpgsm) — there is no built-in
crypto, so keys, certificates, trust, and agents are GnuPG's. Both run last in
the pipeline (so earlier sanitization is applied to the encrypted content) and
are fail-closed: once an encrypt flag is set, sanimail never emits the
message in the clear — an unparseable message is encrypted wholesale and a crypto
failure exits non-zero so the MTA retries.
The motivating use case is a personal mail gateway: decrypt inbound PGP and
re-encrypt as S/MIME so a client with native S/MIME but no PGP (e.g. Apple Mail
on iOS) can read it. PGP and S/MIME are mutually exclusive on the outgoing
message, but --pgp-decrypt and --smime-decrypt may run together:
sanimail --pgp-decrypt --pgp-gnupghome ~/.gnupg \
--smime-encrypt ~/certs/me.pem --smime-gnupghome ~/.gnupg-smime
PGP flags:
--pgp-encrypt SELECTOR Encrypt the message (PGP/MIME, RFC 3156)
to this recipient, via gpg. SELECTOR is
either a gpg recipient (key id,
fingerprint, or email, resolved against
the keyring) or a path to a key file.
Trust is not required (gpg is run with
--trust-model always). Repeat the flag to
encrypt to multiple recipients; any one of
them can decrypt.
--pgp-sign SELECTOR Sign the message (PGP/MIME) with this gpg
key (key id, fingerprint, or email).
Resolved via the GnuPG keyring/agent, so
smart-card / YubiKey keys work.
--pgp-sign-and-encrypt SELECTOR
Shorthand for --pgp-sign SELECTOR
--pgp-encrypt SELECTOR: sign with, and
encrypt to, the same key without naming it
twice. Additional --pgp-encrypt recipients
still apply. Mutually exclusive with
--pgp-sign.
--pgp-decrypt Decrypt incoming PGP/MIME and inline-PGP
content (via gpg) before the other
transforms, so the rest of the pipeline
sanitizes the plaintext.
--pgp-skip-encrypted With --pgp-encrypt, pass already
fully-encrypted messages (PGP/MIME, S/MIME
enveloped-data, or a message that is solely
an inline PGP block) through unchanged
instead of re-encrypting. Partially-
encrypted mail is still encrypted.
--pgp-gnupghome DIR Use this GnuPG home directory
(gpg --homedir) for all PGP operations.
Default: $GNUPGHOME or ~/.gnupg.
--gpg-path PATH Path to the gpg binary (for a stripped PATH).
Default: search PATH for "gpg".
--pgp-sign-passphrase-file FILE
Read the signing key's passphrase from
this file (otherwise gpg-agent / pinentry
handles it).
--pgp-decrypt-passphrase-file FILE
Read the decryption key's passphrase from
this file (otherwise gpg-agent / pinentry
handles it).
S/MIME flags:
--smime-encrypt SELECTOR Encrypt the message (S/MIME enveloped-data)
to this recipient, via gpgsm. SELECTOR is
either a certificate already in the gpgsm
keybox (email or fingerprint) or a path to a
certificate file (PEM/DER), which is imported
automatically and addressed by its
fingerprint. Trust is not required (gpgsm is
run with --always-trust), so a self-signed
recipient cert works. Repeat to encrypt to
multiple recipients; any one can decrypt.
--smime-sign SELECTOR Sign the message (S/MIME, detached
multipart/signed) with this gpgsm key (email
or fingerprint), resolved via the keybox/agent.
--smime-sign-and-encrypt SELECTOR
Shorthand for --smime-sign SELECTOR
--smime-encrypt SELECTOR. Additional
--smime-encrypt recipients still apply.
Mutually exclusive with --smime-sign.
--smime-decrypt Decrypt incoming S/MIME enveloped-data (via
gpgsm) before the other transforms. May be
combined with --pgp-decrypt (each is a no-op
on a message of the other scheme).
--smime-skip-encrypted With --smime-encrypt, pass already
fully-encrypted messages through unchanged
instead of re-encrypting.
--smime-gnupghome DIR Use this GnuPG home directory
(gpgsm --homedir) for all S/MIME operations.
Default: $GNUPGHOME or ~/.gnupg.
--gpgsm-path PATH Path to the gpgsm binary (for a stripped PATH).
Default: search PATH for "gpgsm".
--smime-sign-passphrase-file FILE
Read the signing key's passphrase from this
file (otherwise gpg-agent / pinentry handles it).
--smime-decrypt-passphrase-file FILE
Read the decryption key's passphrase from this
file (otherwise gpg-agent / pinentry handles it).
Header Protection (RFC 9788)
sanimail can apply RFC 9788
header protection to the messages it signs and encrypts, with either scheme.
Headers are copied into the cryptographic payload (where they gain end-to-end
integrity) and the chosen --hp policy controls what is obscured on the outer
cleartext headers. Off by default — opt in with --hp. sanimail's own
decrypt transforms unwind it automatically, restoring the real headers.
--hp POLICY RFC 9788 header protection. Off by default;
opt in with a POLICY. Headers are embedded in
the payload for integrity; POLICY controls
what is obscured on the OUTER (cleartext)
headers when encrypting:
off - no header protection (default)
none - protect for integrity, obscure
nothing
baseline - Subject -> "[...]",
Comments/Keywords removed
shy - baseline, plus From/To/Cc
reduced to addr-spec (display names
dropped) and Date converted to UTC
Signing alone is always hp="clear" (every
header integrity-protected, none obscured).
--hp-confidential HDR,...
Extra headers to obscure on the outer
envelope beyond the policy.
--hp-expose HDR,... Headers to keep visible on the outer
envelope, overriding the policy and
--hp-confidential.
More detail — how encryption is applied (MIME shapes, sign-then-encrypt
nesting), the automatic Microsoft Exchange PGP repair, how gpg/gpgsm run
under the sandbox, incoming-signature behaviour, self-signed certificate setup
for a personal gateway (with an openssl recipe), the offline-by-design S/MIME
posture, and header-protection mechanics are in
docs/encryption.md.
Logging
Sanimail logs to stderr, never stdout. By default it emits one Info-level
summary line per message with duration, byte counts, modification status,
and warning count. Use --verbose for Debug-level per-operation detail
(individual MIME part transforms, header deletions).
Every log line includes a message_id field — extracted from the
Message-Id header if present, otherwise generated as
<base64url@sanimail>.
Use --log-format=json for structured JSON output suitable for log
aggregation pipelines.
Deployment
Sanimail runs as a filter inside a mail delivery pipeline — a Dovecot Sieve
pipe/execute action, a Postfix/maildrop filter, a .forward/procmail pipe,
and so on. Delivery agents run filters in a stripped environment with tight
resource limits, so a few details matter: the Go runtime's virtual-memory
reservation vs. Dovecot's vsz_limit, Sieve execution timeouts vs.
--remote-total-timeout, putting gpg on PATH, --output-pipe delivery
commands, and configuring fail-safe fallback so a filter error never loses mail.
See docs/deployment.md for the full guide.
Hardening
Sanimail applies opportunistic OS-level sandboxing at startup, before any input is read. The sandbox is configured based on the flags provided:
- Landlock (Linux 5.13+) — denies all filesystem access. Network
access (TCP connect/bind) is also denied on Landlock V4+ (Linux 6.7+),
unless a feature that requires outbound HTTP is enabled (e.g.
--remote-inline), in which case only filesystem access is restricted. - seccomp (Linux 3.17+) — installs a BPF syscall allowlist restricted to the ~30 syscalls the Go runtime needs (memory, signals, scheduling, epoll, pipes, and I/O on already-open file descriptors). When network access is needed, socket and DNS-related syscalls are added to the allowlist. All other syscalls return EPERM.
Both mechanisms degrade gracefully: on older kernels, unsupported architectures, or non-Linux platforms, sandboxing is silently skipped and mail flows normally.
Subprocesses that need to run — the gpg helper for PGP operations and the
--output-pipe delivery command — are spawned before the sandbox closes,
since execve is not on the seccomp allowlist. The sandboxed pipeline then
drives them over pipes, writing to and reaping the already-running children
(the allowlist permits write, waitid/wait4, and pidfd_send_signal
for exactly this).
The GnuPG helper is additionally started inside an empty network
namespace (via an unprivileged user namespace with an identity uid/gid
mapping). gpg and gpgsm parse attacker-controlled ciphertext and
certificates and are run fully offline anyway, so this costs nothing — but a
parser exploit in either lands in a process that cannot reach the network,
and so cannot exfiltrate private keys or plaintext. gpg-agent and its unix
sockets are unaffected. Like the rest of the sandboxing this is
opportunistic: if the host forbids unprivileged user namespaces (old kernel,
hardened sysctl, a container runtime's seccomp profile), the helper starts
unconfined and mail flows normally; --verbose logs which mode was reached
(network_confined=true|false).
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Runtime error (I/O failure reading stdin or writing stdout/output, or the --output-pipe command exiting non-zero) |
| 2 | Usage error (unknown flag, invalid glob pattern, bad --generate-plain value) |
License
See LICENSE. sanimail is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0).
A separate commercial license is available for those who cannot or do not wish to comply with the AGPL — open an issue or get in touch to arrange one. This is possible because the author holds the copyright to all original code; contributions are therefore accepted only under terms that grant the author the right to relicense them under any license, including a commercial one (see CONTRIBUTING.md).
The commercial license covers sanimail's own code only; third-party dependencies remain under their respective (permissive) licenses, and the embedded ClearURLs data remains under LGPL-3.0 (see Third-party data below).
Third-party data
The --detrack-urls feature embeds a snapshot of the
ClearURLs rules catalog, licensed under
the GNU Lesser General Public License v3.0 (LGPL-3.0) and redistributed under
email/urldetrack/data/ with only cosmetic reformatting (pretty-printed with
sorted keys for reviewable diffs; the rules are unchanged — see the LICENSE
and README.md there). LGPL-3.0 is compatible with AGPL-3.0: the combined
binary is conveyed under AGPL-3.0 while the embedded data file retains its
LGPL-3.0 terms.
Acknowledgements
This project was developed by Mike Cardwell, with the assistance of Claude Code, Anthropic's AI coding tool.