Sanimail

Active Pure Nim score 65/100 · last commit 2026-08-10 · 1 stars · tests present · no docs generated

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

pipeline status coverage report latest release Go Reference License: AGPL-3.0

⚠️ 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/gpgsm binary 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-urls removes utm_*, gclid, fbclid and friends, and unwraps redirector links to their real destination — fully offline, driven by the ClearURLs catalog.
  • Sanitizes hostile HTML. --policy runs 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-filenames neutralizes 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-fields breaks HTML tags in calendar (iCal) and contact (vCard) text fields and in rendered header values (Subject, sender display names) — a DESCRIPTION, a NOTE, a Subject — for clients that render those as HTML, catching even tags hidden in a QUOTED-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/plain alternative 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 to text/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-inline fetch 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-Warning header, which no longer exists. Update any filter matching Sanimail-Warning to match Sanimail-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
                                  httpshttp 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: 0 means "unlimited" for each budget independently. Setting --remote-max-count 0 and --remote-max-bytes 0 together 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 .onion works). Per-route scheme=/host= conditions hold on every redirect hop, so e.g. scheme=https on 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 forensic Sanimail-Inlined-Failed header (naming the URL and reason) rather than a quarantine Sanimail-Error. It is a member of the Sanimail-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 style and <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, the ping/manifest tracking attributes, and sketchy CSS (position, @import, @font-face, expression(), IE behavior/-moz-binding, and remote fetches via content/cursor/list-style-image). Keeps layout, inline styles, <style> blocks, background images, and data: 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 protonizer config).

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%2Fhttps://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-urls also 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 9073 STYLED-DESCRIPTION) are run through the full HTML pipeline, and clickable/resource URLs are de-tracked and scheme-checked.
  • Contact cards (text/vcard) — remote PHOTO/LOGO URLs (tracking pixels by another name) are de-tracked and, with --remote-inline, fetched and embedded in the card; inline SVG photos are sanitized; the URL homepage 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.

Support/Appreciate my work