MailForensics
strace for an email moving through your mail stack.
By Artur Panek · Project page
mailforensics reconstructs and explains an outbound message by correlating evidence from applications, Postfix, Rspamd/milters, live queues, handoffs, and relays.
It is intentionally not an email-header analyzer and not an always-on monitoring daemon. Point it at evidence when a message disappears and ask one question: where did this mail go?
Early alpha. v0.5.0 is focused on local, evidence-driven mail forensics.
Origin story: this started during a rage-fix session after one SMTP invite path refused to explain where the mail was disappearing.
Forensic terminal identity
The full banner appears in root help and demos, not on every normal trace:
╭──────────────────╮
──────┤ MAILFORENSICS ├──────▶
╰──────────────────╯
trace the evidence,
not the guess.
Interactive output uses terminal color only when appropriate. NO_COLOR is respected, and --color auto|always|never plus --ascii make the behavior explicit.
The useful command
mailforensics explain \
--journal \
--since '20 minutes ago' \
--live-queue \
--message-id '<invite-123@example.net>'
Example:
MAILFORENSICS EXPLAIN
query: message-id=<invite-123@example.net>
status: deferred
Pipeline
● APP korpoappka/invite
│ 92ms
▼
● POSTFIX postfix/cleanup
│ 34ms
▼
● FILTER Rspamd: no action
│ 11ms
▼
● QUEUE queue 0DC461ACD87
│ 842ms
▼
◐ RELAY deferred via gmail-smtp-in.l.google.com
│ 2.12m
▼
◐ QUEUE NOW still present in deferred queue
Latency total observed: 2.14m
korpoappka:invite -> postfix:cleanup 92ms
postfix:cleanup -> rspamd:filter 34ms
rspamd:filter -> postfix:qmgr 11ms
postfix:qmgr -> postfix:smtp 842ms
postfix:smtp -> postfix-queue:deferred 2.12m
Assessment
outcome: queued-for-retry
last stage: postfix-queue:deferred
confidence: high
Postfix deferred delivery and the message is still present in the live queue.
Caveat: Postfix can retry later according to its queue schedule.
The output is deliberately conservative. If the evidence ends, mailforensics says where it ends instead of inventing a failure.
Install
PyPI
Once v0.5.0 is published:
python -m pip install mailforensics
Then:
mailforensics demo
mailforensics doctor
From source
Until the first PyPI release, install directly from GitHub:
git clone https://github.com/artur-panek/mailforensics.git
cd mailforensics
python -m venv .venv
source .venv/bin/activate
python -m pip install -e .
Python 3.11+ is required.
Zero-setup demo
mailforensics demo
mailforensics demo delivered
mailforensics demo rejected
mailforensics demo gap
The default demo is a deferred message still visible in the live queue. It requires no Postfix installation or log files, which makes it useful for evaluating the project from a fresh clone.
Environment doctor
mailforensics doctor
The doctor checks Python, journalctl, journal readability, postqueue, queue access, default mail logs, and parser plugins. Missing optional local capabilities are warnings rather than fake failures.
Shell completion
Print a completion script:
mailforensics completion bash
mailforensics completion zsh
mailforensics completion fish
Repository convenience stubs also live under completions/.
Example fixtures
Four small evidence sets live under examples/fixtures/:
delivered/deferred/rejected/gap/
For example:
mailforensics explain \
--file examples/fixtures/deferred/mail.log \
--queue-file examples/fixtures/deferred/queue.jsonl \
--queue 0DC461ACD87
Exit codes
0: trace found / command succeeded1: no matching trace, or doctor found a hard failure2: invocation or runtime error
Commands
Trace the full evidence timeline
Legacy syntax remains supported:
mailforensics --journal --since '10 minutes ago' --message-id '<3927a888@example.net>'
The explicit form is equivalent:
mailforensics trace --journal --since '10 minutes ago' --message-id '<3927a888@example.net>'
Explain the pipeline
mailforensics explain --file /var/log/mail.log --queue 0DC461ACD87
This gives a compact pipeline, latency breakdown, and evidence-based assessment.
Inspect the current Postfix queue
Add postqueue -j as live evidence:
mailforensics explain \
--journal \
--since '2 hours ago' \
--live-queue \
--queue 0DC461ACD87
Or analyze a saved queue snapshot:
postqueue -j > queue.jsonl
mailforensics explain --queue-file queue.jsonl --queue 0DC461ACD87
If a deferred message still exists in the queue, the assessment can distinguish old deferred evidence from currently queued for retry.
Correlate application/gateway events
Custom services can emit tiny JSONL events:
{"timestamp":"2026-10-04T04:36:47+00:00","source":"korpoappka","stage":"invite","kind":"submitted","correlation_id":"invite-42","message_id":"3927a888@example.net","recipient":"tester@example.com","status":"submitted","message":"alpha invite handed to mail gateway"}
Then:
mailforensics explain \
--events app-mail-events.jsonl \
--journal \
--since '30 minutes ago' \
--correlation-id invite-42
The strongest bridge is an application correlation_id plus a Message-ID or queue ID.
Query by recipient
mailforensics explain --journal --since today --to tester@example.com
Recipient lookup intentionally selects the latest matching message in the supplied evidence window before expanding by Message-ID/queue ID. It does not merge every email sent to that address.
Export JSON
mailforensics explain --journal --since today --queue ABC123 --json
JSON includes the assessment, pipeline, latency spans, identifiers, and raw normalized events.
Generate a self-contained HTML report
mailforensics explain \
--journal \
--since '30 minutes ago' \
--queue ABC123 \
--html mailforensics-report.html
The report contains no external JavaScript or assets.
List parsers
mailforensics parsers
Built-in parsers currently cover Postfix and Rspamd. Third-party packages can register parser adapters through the mailforensics.parsers Python entry-point group.
Inputs
mailforensics can combine all of these in one run:
- classic syslog mail logs
- ISO/journald-style syslog
- RFC 5424 syslog
- direct
journalctlcollection - Postfix
postqueue -jsnapshots - Rspamd task/proxy logs
- Postfix milter reject/discard events
- structured JSONL application/gateway events
- external parser plugins
Correlation model
The correlation graph expands through strong identifiers:
correlation_id
│
▼
Message-ID
│
▼
Postfix queue ID ─── queued as ─── next queue ID
│
├────────────── Rspamd
│
├────────────── live postqueue
│
└────────────── SMTP/LMTP/local delivery
Timestamp proximity alone is not a correlation key.
See docs/correlation.md.
Evidence semantics
A few distinctions are intentionally explicit:
- Postfix
status=sentmeans the configured next hop accepted the message. - It does not prove inbox placement.
- A live queue entry proves the queue item exists at capture time, but not why delivery is delayed.
- Missing events are an evidence boundary, not proof that a service failed.
- An earlier
deferredfollowed by a latersentis treated as successful next-hop acceptance. - Rspamd reject evidence and Postfix milter-reject evidence are reported separately.
Structured application events
Applications and gateways can join the trace without a custom parser by writing JSON Lines.
See docs/structured-events.md.
Parser plugins
A parser package can register an entry point:
[project.entry-points."mailforensics.parsers"]
amavis = "mailforensics_amavis:parse"
The callable receives the same log lines as the built-in parsers and returns mailforensics.model.Event objects.
Use --no-plugins when you want a run limited to built-in parsers.
What v0.5 ships
- Postfix trace correlation
- Message-ID and queue-ID handoffs
- direct journald input
- Rspamd correlation
- Postfix milter reject/discard evidence
- structured application/gateway events
- RFC 5424 ingestion
- live Postfix queue inspection
- per-event latency breakdown
- compact
mailforensics explainpipeline - evidence-based assessment
- parser plugin entry points
- JSON output
- self-contained HTML reports
Next
Useful extensions that still fit the project:
- Exim/OpenSMTPD adapters
- DSN/bounce-message ingestion
- better queue-age and retry-schedule explanation
- optional Graphviz/DOT export
- sanitized diagnostic bundles for sharing traces
- more real-world Rspamd/milter fixtures
Development
python -m pip install -e '.[dev]'
ruff check .
pytest
License
MIT
Metadata
Release files for mailforensics 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mailforensics-0.5.0.tar.gz | 34.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mailforensics-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 67.9 kB
Release files / mailforensics-0.5.0.tar.gz
| Download URL | mailforensics-0.5.0.tar.gz |
|---|---|
| Size | 34.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9f00fb6cc5285dcd62ae5f933c9a2ab7567628743ae7581c68da6f3f4e01a099
|
|
BLAKE2b-256 checksum How to use checksums |
0b9a70a27ba063214d88f9a0b6d99cef86b5e2f597e370b618ff1f75e3b6dec9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.
Transparency logRelease files / mailforensics-0.5.0-py3-none-any.whl
| Download URL | mailforensics-0.5.0-py3-none-any.whl |
|---|---|
| Size | 33.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
dfda9ebc1f57697e3a60babd868a59f050548e482bf88631b89da11b9d309be2
|
|
BLAKE2b-256 checksum How to use checksums |
e365682fe6374907978dbb29657855728377bb68aa1aa28d86ef8103aa706974
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.
Transparency log