English · Português (Brasil)
MailVigil is an offline, zero-dependency DMARC report analyzer: a command-line tool that reads
the aggregate reports (RUA) your mail providers send you and tells you, in plain language, who is
sending mail as your domain and whether it is safe to move your policy to p=reject.
Everyone who deploys DMARC receives a daily XML file (usually inside a .zip or .gz) from each
provider, and nobody reads them. The result is people who stay on p=none forever, afraid of
blocking legitimate mail. MailVigil reads them for you, groups the traffic by sender, and gives a
verdict with an explicit, documented criterion instead of a wall of numbers.
It is built for site owners, developers, agencies and infrastructure freelancers who are rolling out DMARC, and it runs entirely on your machine: your reports are never sent to anyone.
Status: stable, from
1.0.0. The commands, the exit codes, the--jsondocuments and the configuration file are a contract that changes only in a major version; what is public and what is not is in docs/stability.md.
Install: pipx install mailvigil or uv tool install mailvigil (Python 3.12 or newer, nothing
else). Then mailvigil verdict --config mailvigil.toml reports/.
Website: mailvigil.ryanmorais.com.br/en/
Two real runs on invented reports (example.com, documentation addresses): the first period is
NOT READY and the command exits with 4; after the sender fixes its DKIM key, a month of newer
reports gives READY and 0. The commands are typed for the recording and the wait between the
two runs is cut. The recording is docs/demo/verdict.cast, made by
docs/demo/build_demo.py; play it with asciinema play.
MailVigil answers one question: is it safe to move your DMARC policy to p=reject? It reads the
aggregate reports your providers send, groups the traffic by sender, and gives a verdict with a
stated criterion. The verdict is READY only when all five rules hold, TOO EARLY when there is
too little data yet, and NOT READY when a source is failing (an example):
- the reports cover at least 28 days;
- reports arrived on at least 80% of those days;
- at least 100 messages were seen;
- no message from a recognized sender failed;
- at most 0.5% of the messages from unrecognized sources failed.
| Command | What it tells you | Reads | Network |
|---|---|---|---|
summary |
Who sends mail as your domain: per sender and per situation (ok, fragile, failing), failures first | A folder or files: .xml, .zip, .gz, .eml, .mbox |
None |
verdict |
Ready for p=reject, with each rule against its limit. --html writes one static page (HTML report); --json writes a document for scripts and CI (JSON) |
The same | None |
fetch |
Downloads the report messages of a mailbox into a folder; read-only, over verified TLS (Fetch) | Your mailbox, over IMAP | IMAP only |
Also: several domains in one run (Several domains), the text in English or
Brazilian Portuguese with --lang pt-BR (Language) and stable exit codes
(Exit codes).
- Offline. Your reports are never sent to anyone. Only
fetchuses the network, and only to reach your own mailbox. - Hostile files are expected. The decompressed size is capped (zip bombs) and XML with
DOCTYPEorENTITYis refused (entity expansion). - A stated criterion. Every verdict shows the rule that decided it and says what the reports do not cover.
- A contract. The commands, the exit codes, the
--jsondocuments and the configuration file change only in a major version (docs/stability.md).
Not planned, on purpose: indexing in Elasticsearch, a web dashboard served by the tool and sending e-mail, each with its reason in Permanent non-goals.
MailVigil names a sender from what its reports say (a DKIM selector, an SPF domain or the address it came from), using maps you write. The file is TOML, and the names below are invented:
domain = "example.com"
[senders]
newsletter = "Example Mailer (newsletters)"
mailbox = "Example Mail (staff mailboxes)"
"k1.v2" = "Example Campaigns"
[spf]
"bounce.example.com" = "Example Relay (SPF only)"
[ips]
"192.0.2.0/24" = "Example Office (own mail server)"- Start with an empty
[senders]and fill it from what the first summary shows as unknown. - A selector must identify one service. Names such as
defaultare shared by unrelated services, so MailVigil ships no built-in table and shows an unlisted sender as unknown. - A selector with a dot is written in quotes (
"k1.v2"). Without them TOML readsk1.v2as a nested table and MailVigil refuses the file. - A selector alone is not trusted. It names a service only on a passing DKIM signature under your domain or one of its subdomains; the same selector under another domain is reported as a foreign domain.
[spf]names a sender that authenticates by SPF alone. The key is the domain the report shows under SPF (abounce.subdomain of yours, say), it must be your domain or a subdomain of it, and the match is exact. A domain of a shared service (amazonses.com) can never align with your domain, so MailVigil refuses it instead of keeping a line that never matches. When a row is unrecognized, the summary lists the SPF domains that passed under your domain on it.[ips]names a sender by address range (a CIDR block or a single address, IPv4 or IPv6) and only counts on a record that also passed DKIM or SPF under your domain: the address says who connected, not that the mail is yours. Use it for servers you alone control; a range on shared hosting would name every tenant. A range wider than a/16(IPv4) or a/32(IPv6) is refused. The narrowest range wins.- Quote the keys of
[spf]and[ips]. They hold dots, slashes or colons, which TOML reads as nested tables or refuses. - The order is selector, then SPF domain, then address range. The first that holds names the sender; a failed or foreign selector does not stop an SPF domain or a range from naming it.
- The file is strict. An unknown key (
domian), a duplicate selector or a control character in a name is an error with a reason, never a silent guess, because a typo would change the answer.
mailvigil summary --config FILE reads it. Without --config the command reads mailvigil.toml
in the current folder, if there is one; with neither a file nor --domain it asks for one.
The same file can also hold the mailbox settings of mailvigil fetch, in a [fetch] table
(see Fetch); the password is never one of its keys.
One file can look after several domains: an array of tables, [[domains]], each with its own
identity maps. The file is one form or the other, never a blend: with [[domains]] the top-level
domain, [senders], [spf] and [ips] are refused. [fetch] stays at the top level.
[[domains]]
domain = "example.com"
[domains.senders]
newsletter = "Example Mailer (newsletters)"
[domains.spf]
"bounce.example.com" = "Example Relay (SPF only)"
[[domains]]
domain = "example.org"
[domains.senders]
shop = "Example Shop (receipts)"- Each domain keeps its own maps. A selector, an SPF domain or an address range names a sender
for the domain it is written under and for no other; an
[spf]key must be within its own domain. --domainpicks, it does not add. Without it, a run covers every domain of the file. With one or more--domainvalues (repeat the option) it covers only those, in the order of the file, and a value that is not in the file is refused. Without a file, each--domainis a domain with empty maps, as for one domain.- The reports are read once, whatever the number of domains, and each report goes to the domain
it belongs to. A domain with no report yet is
too early; the run is refused only when no domain has a report. - At most 50 domains, and the same domain twice (even as
bücher.exampleand itsxn--form) is a duplicate and is refused. summaryandverdictprint one block per domain, set apart by a rule line;verdictends with a line that counts the answers. There is no combined answer, because one for a mix of domains would say nothing about any of them.
An internationalized domain can be written as it is read: bücher.example in domain, in
--domain or as an [spf] key becomes xn--bcher-kva.example, the form the reports carry, and
that is what is printed. The conversion is the standard library's idna codec (IDNA 2003), so a
name it would change (faß.example becomes fass.example), that is not valid or that uses a
character newer than Unicode 3.2 is refused with the advice to write its xn-- form, which is
always accepted.
| MailVigil | parsedmarc | Hosted services (dmarcian, EasyDMARC, Postmark) | |
|---|---|---|---|
| Shape | One small CLI, standard library only | Parser and pipeline for Elasticsearch, Splunk and Kibana | Web dashboards |
| Where your reports go | Nowhere: offline | Your own stack | To a third party |
| Answer | A verdict on p=reject with a stated criterion |
Parsed data (JSON, CSV, dashboards) | Dashboards and recommendations |
| Best for | Deciding if you can enforce DMARC, from the terminal | Continuous monitoring at scale | Teams that want a managed product |
MailVigil is not a replacement for a monitoring pipeline: if you already run parsedmarc, keep it. It answers one question, and answers it without a stack to install.
Before there are reports to read, DMARC Fácil checks the DMARC, SPF and DKIM records of a domain in your browser (the interface is in Portuguese).
MailVigil is deliberately small. For each limit below, what to reach for instead.
- It reads only the reports you give it. Mail that no receiver reports on, and senders that were quiet in the period, are not covered, and the verdict says so under every answer. Instead: collect a full cycle first; the verdict asks for 28 days.
fetchhas no OAuth. It logs in with a name and a password, so Microsoft 365 and Google accounts without an app password are not supported (see Fetch). Instead: save the report messages from your mail client into a folder as.emlfiles and pointsummaryat it. The same goes for a provider with no IMAP.- It is not a monitoring pipeline. There are no dashboards, no history and no alerts, and a web dashboard is not planned (see Permanent non-goals). Instead: parsedmarc with Elasticsearch or Kibana.
Install it as a command, with pipx or uv. It needs Python 3.12 or newer and nothing else.
# Install the command (one of the two)
pipx install mailvigil
uv tool install mailvigil
# Check that the command runs
mailvigil --version
# Sum up a folder of reports (or name the files); mailvigil.toml is read if it is here
mailvigil summary reports/
mailvigil summary --config mailvigil.toml reports/
mailvigil summary --domain example.com report.xml.gz
# Ask if the domain is ready for p=reject (same arguments as summary)
mailvigil verdict --config mailvigil.toml reports/
# Several domains: [[domains]] in the file, or --domain repeated (one block each)
mailvigil verdict --domain example.com --domain example.org reports/
# The same answers as one JSON document, for a script or a CI job
mailvigil verdict --config mailvigil.toml reports/ --json
# The text in Brazilian Portuguese (English is the default)
mailvigil verdict --config mailvigil.toml reports/ --lang pt-BR
# One HTML page to open in a browser (redirect it to a file)
mailvigil verdict --config mailvigil.toml reports/ --html > report.html
# Bring the report messages home from a mailbox (asks for the password), then read them
mailvigil fetch --host imap.example.com --user me@example.com --to reports/summary and verdict print their text and nothing else; they only read your files and touch no
network. Only fetch connects to a server (see Fetch).
A folder is searched, one level deep, only for the names providers use for their attachments
(provider!domain!begin!end.zip, also .gz and .xml) and for .eml and .mbox files, whose
report attachments are taken out of the messages in memory. A file named on the command line is
read whatever its name. Add --recursive to search the subfolders too (up to 8 levels; a linked
folder is not entered). A .zip, .gz or .xml file with another name is not read, but the
"left out" block counts it as "passed over by name", so a renamed report is not silent: name the
file to read it.
What summary prints (invented data)
MailVigil summary for example.com
config mailvigil.toml (3 selectors)
period 2026-10-20 to 2026-10-20 (1 of 1 day has a report)
reports 1 from 1 reporter
policy p=none, sp=none, pct=100
messages 7335 (7250 from recognized senders, 98.8%)
Situations
ok 6300 85.9%
fragile 975 13.3%
failing 60 0.8%
Senders
sender ok fragile failing total
Example Mailer (newsletters) 6300 0 0 6300
Example Mail (staff mailboxes) 0 950 0 950
! 203.0.113.5 (unrecognized) 0 0 40 40
198.51.100.99 (failing signature) 0 25 0 25
! 203.0.113.9 (unrecognized) 0 0 12 12
! 2001:db8::7 (unrecognized) 0 0 8 8
! FAILING: 60 messages in 3 sources
Unrecognized sources that fail
! 203.0.113.5
40 messages dkim: fail, spf: fail
! 203.0.113.9
12 messages dkim: none, spf: fail
! 2001:db8::7
8 messages dkim: none, spf: none
Left out of these numbers
skipped files 0
duplicates 0
other domains 0
A ! marks a sender with failing messages. Files that were refused, duplicates and reports of
another domain are counted at the bottom, so a number never silently drops out.
mailvigil verdict answers one of three things, each with the rule that decided it. This is a
NOT READY (invented data):
MailVigil verdict for example.com: NOT READY for p=reject
Why: failures from unrecognized sources are 0.8% of the messages (56 of 7056);
the limit is 0.5%.
203.0.113.5 unrecognized: 56 failing messages
Rules
rule measured needed result
1 period covered 29 days >= 28 pass
2 days with a report 29 of 29 (100%) >= 80% pass
3 messages seen 7056 >= 100 pass
4 recognized failures 0 <= 0 pass
5 unrecognized failures 56 (0.8%) <= 0.5% FAIL
Next: look at the sources above and add the ones that are yours to [senders]
(DKIM selector), [spf] (SPF domain) or [ips] (address range) in the
configuration.
This reads only the reports you gave it: mail that no receiver reports on, and
senders that were quiet in this period, are not covered.
READY means every rule passes. TOO EARLY means the data rules (1 to 3) fail and nothing is
failing yet: wait for more reports. NOT READY means a source fails (rules 4 and 5), and the
command names it.
When files were refused, duplicated or from another domain, the verdict adds one line saying so;
mailvigil summary shows why. They never change the answer.
mailvigil verdict --html prints one self-contained HTML page instead of the text, for reading the
answer on a screen or sending it to someone who will not open a terminal. The page opens with the
answer, then shows each of the five rules as a meter against its limit, the split of the messages
in four situations, one column per day of the period, the senders and the failing sources. It
follows --lang, switches to dark with the system, and prints on paper.
The page for an invented domain (example.com, documentation addresses), in the light and the dark palette: unrecognized sources fail 0.8% of the messages (56 of 7056), above the 0.5% limit, so the answer is NOT READY.
mailvigil verdict --config mailvigil.toml reports/ --html > report.html- It is a file, not a server. The page has no script, loads nothing from the network and declares a content security policy that forbids both; the text from your reports is escaped before it reaches the markup. Open it from disk.
- It carries no path and no file name, only what the reports say, so it can be shared as it is once you are happy to show the domain and the sending addresses in it.
- The exit code is the same as without
--html(0,3,4), and--htmlcannot be combined with--json.summaryhas no--html. --output FILEwrites the page itself, as ASCII bytes, so no shell sits in between:mailvigil verdict --config mailvigil.toml reports/ --html --output report.html. It replaces a file already there, never creates the folder, prints nothing when it works and needs--html. Use it in Windows PowerShell 5.1, whose>writes UTF-16, which browsers do not read as the page intends. The page is ASCII, so any other shell's>is fine too.- Several domains write a folder. With two or more domains,
--outputnames an existing folder and the command writes one page per domain,mailvigil-<domain>.html(mailvigil-xn--bcher-kva.example.htmlfor an internationalized one), each the page of that domain alone; without--outputthe run is refused, because several pages cannot go to one stream. A page that cannot be written ends the run naming that file; the pages before it are complete. - The senders table stops at 50 rows, so a domain under heavy spoofing does not make a page of
megabytes. A line after the table says how many senders and messages are not listed, and the total
row still counts everything.
--top Nmoves the stop;mailvigil summary --jsonlists every sender.
mailvigil fetch saves the DMARC report messages of a mailbox, untouched, as .eml files in a
folder; summary and verdict read that folder afterwards. It is the one command that opens a
network connection, and summary and verdict never load its code.
# Ask for the password on the terminal
mailvigil fetch --host imap.example.com --user me@example.com --to reports/
# Only recent mail, at most 200 messages, the password read from a file (for cron)
mailvigil fetch --host imap.example.com --user me@example.com --to reports/ \
--since 2026-10-01 --limit 200 --password-file ~/.mailvigil-passwordTo stop repeating the server in every command, put it in the configuration file and pass
--config FILE:
domain = "example.com"
[fetch]
host = "imap.example.com"
user = "me@example.com"
port = 993 # optional, 993 is the default
folder = "INBOX" # optional, INBOX is the defaultmailvigil fetch --config mailvigil.toml --to reports/ --password-file ~/.mailvigil-passwordOnly host, user, port and folder can be there. A flag wins over the file, and fetch
reads a file only when you name it with --config (it never looks for a mailvigil.toml by
itself). The file still needs its domain, like for the other commands. The password is not a key:
password = "..." is refused, and the password keeps coming from the terminal or
--password-file. --to, --since, --limit and --cafile stay flags.
What it does:
- Looks in
INBOX(or--folder) for the messages whose subject hasReport Domain:, the subject providers use for their reports, and saves each one as<tag>-<validity>-<uid>.eml. - Is incremental: run it again and only the new messages are saved, so it can run from
cron.--limit(default 500, at most 10000) caps one run and takes the newest first. - Prints six numbers (matched, saved, already saved, too large, skipped, left for the next run)
and the folder, never a subject, sender or address. With
--jsonthe six numbers come as one JSON document instead (no folder in it), for a script that runsfetchfromcron.
What it does not do:
- It never changes the mailbox: no delete, move, flag, and the messages are not marked as read.
- It never connects without TLS, and the certificate is always checked. There is no option to turn
that off, no plain IMAP and no STARTTLS (
--portdefaults to 993). A server behind a private certificate authority is trusted with--cafile FILE(a PEM file), which adds that authority to the system's and still checks the certificate and the host name. - The password is asked for on the terminal, or read from the first line of
--password-file. It is never an argument or an environment variable, and nothing stores it. - It does not parse the reports: it saves bytes, and the ingestion reads them.
No IMAP at your provider? fetch needs one, and many free mail plans do not offer it. Then
download the report messages by hand (save each as .eml, or save the attachments) into a folder
and point summary or verdict at it: everything but the download works the same. When a login is
refused or the server cannot be reached, the message says so.
Provider limits: it logs in with a name and a password (LOGIN), with no OAuth. Gmail works with
an app password (two-step verification on).
Microsoft 365 and Google accounts without an app password are not supported. A
name or password with a character outside ASCII is sent with AUTHENTICATE PLAIN in UTF-8, and
only if the server offers it; otherwise the run is refused.
Exit codes: 0 when the run finished, even with nothing new; 1 when it was refused (the one-line
reason is printed); 2 for a usage error; 70 for a bug (see below).
The exit code is a contract: a script can tell "yes", "wait" and "no" from "I could not run"
without reading the text. --json does not change it.
| Code | Meaning | Commands |
|---|---|---|
0 |
An answer was printed; for verdict, the answer is ready (for every domain, when there are several); for fetch, the run finished |
all |
1 |
The run was refused: invalid configuration or domain, no report kept, or (for fetch) the connection, login or folder failed |
all |
2 |
Usage error (an unknown option, a missing argument) | all |
3 |
verdict printed too early: wait for more reports |
verdict |
4 |
verdict printed not ready: a source fails |
verdict |
70 |
Internal error, a bug: one line on standard error says where; please report it | all |
130 |
Interrupted with Ctrl+C | all |
141 |
The output pipe was closed (mailvigil verdict ... | head) |
all |
With several domains, verdict exits with the worst answer: 4 if any domain is not ready, else 3
if any is too early, else 0. So 0 means it is safe to move all of them.
Note that 1 is a refusal and 4 is "not ready"; a script that treats every non-zero code alike
mixes them up:
mailvigil verdict reports/ --domain example.com --json > verdict.json
case $? in
0) echo "ready for p=reject" ;;
3) echo "too early: keep collecting reports" ;;
4) echo "not ready: see verdict.json" ;;
*) echo "mailvigil could not run" >&2 ;;
esacA change to this table is a breaking change. docs/stability.md lists what else is public and what is not.
--json on summary, verdict or fetch prints one JSON document and a newline on standard
output, and nothing on standard error. The document is data, not text: integers, booleans, identifiers and
the report values, with no sentence and no rounded percentage. It carries every sender and every
failing source, without the limits the text keeps, and a value that may not exist (no period yet,
no sp) is null. A refused run prints no document: the one line goes to standard error, as
without --json, and the exit code is 1. docs/json-schema.md lists every field and the
compatibility promise. The fetch document is only the six numbers of the run.
With one domain the document is the one described. With two or more it is an envelope: schema_version,
command and a domains list holding the document of each domain exactly as a run of that domain
would print it (for verdict, also answer, the worst answer, which is what the exit code stands
for). A script that handles both looks for the domains key.
A domain under heavy spoofing can have tens of thousands of failing sources. --top N keeps the
first N entries of each long list and adds a last key, top, with the limit and how many entries
each list left out; without it the document is the complete one, byte for byte. The answer and the
exit code do not depend on --top, which needs --json (or --html, on verdict).
The text of both commands and the one-line refusals are in English by default. --lang pt-BR
(pt-br works too) prints them in Brazilian Portuguese; --lang en is the default spelled out, and
any other code is a usage error (exit 2). The Portuguese has its accents (não) when the output
goes to a stream that carries them: any UTF-8 stream on Linux and macOS, and a terminal on Windows.
A pipe or a file on Windows (> file, | more, Git Bash) and any other encoding get the same text
without accents (nao), letter for letter, so no console can show it wrong and the layout never
moves. The --html page always has the accents. If you want them in a pipe, write the page.
The same invented NOT READY as above, in Portuguese
Veredito do MailVigil para example.com: NÃO PRONTO para p=reject
Motivo: as falhas de origens não reconhecidas são 0.8% das mensagens (56 de
7056); o limite é 0.5%.
203.0.113.5 não reconhecido: 56 mensagens com falha
Regras
regra medido necessário resultado
1 período coberto 29 dias >= 28 passa
2 dias com relatório 29 de 29 (100%) >= 80% passa
3 mensagens vistas 7056 >= 100 passa
4 falhas reconhecidas 0 <= 0 passa
5 falhas não reconhecidas 56 (0.8%) <= 0.5% FALHA
Próximo: olhe as origens acima e acrescente os seletores das que forem suas a
[senders] na configuração.
Isto lê apenas os relatórios que você deu: email que nenhum receptor reporta, e
remetentes que ficaram quietos neste período, não são cobertos.
Numbers keep the decimal point in both languages, --json has no language (its identifiers do not
change), --help stays in English, and neither the language nor --json changes an exit code.
Python 3.12 is the floor (CI tests 3.12 to 3.15, on Linux and on Windows). The quality gate runs before every commit through a versioned hook:
# Activate the pre-commit hook once per clone
git config core.hooksPath .githooks
# Lint (the hook and the CI run the same four steps, in this order)
uv run ruff check .
# Check the formatting
uv run black --check .
# Check the types (strict)
uv run mypy
# Run the tests with coverage
uv run pytest --covEach technology of the stack, and why it was chosen, is explained in docs/stack.md. Features are specified under specs/ before they are built.
Contributions are welcome: see CONTRIBUTING.md. To report a vulnerability in MailVigil itself, use the private channel described in SECURITY.md, never a public issue.