Tracework is a local investigation workbench that runs MailTrace and InfraChronicle in one process:
| Module | Role |
|---|---|
| MailTrace | Offline triage of suspicious .eml files (headers, URLs, attachments, IOC export). |
| InfraChronicle | Passive infrastructure OSINT (DNS, Certificate Transparency, RDAP), SQLite observation store, graph, and timeline. |
The Python package is still named mailtrace on PyPI/install paths; the product UI is Tracework.
mailtrace serveOpens http://127.0.0.1:8000/ — hub with MailTrace (/email/) and InfraChronicle (/infra). Inside each module, Tracework in the corner returns to the hub. MailTrace CLI commands analyze, ioc, and dns-policy work without the web UI.
MailTrace does not open links, execute attachments, unpack archives, or upload the message during analysis. Missing SPF, DKIM, or DMARC headers are recorded as missing context, not proof of phishing. InfraChronicle uses allowlisted providers only (no port scanning).
Python 3.12 or newer:
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"mailtrace is then on PATH. The dev extra installs pytest, Ruff, and mypy.
docker compose up -d --build
docker compose logs -f traceworkOpen http://127.0.0.1:8000/. Investigation data lives in the tracework_data volume (DATABASE_URL and DATA_DIR in docker-compose.yml).
source .venv/bin/activate
mailtrace serve
mailtrace serve --host 0.0.0.0 --port 8000 # LAN only; trusted networksUse TLS, authentication, and network isolation before exposing the UI beyond a lab.
Optional settings: copy .env.example to .env (DATABASE_URL, DATA_DIR, rate limits, optional InfraChronicle API keys).
From the hub, open InfraChronicle to run investigations, browse the relationship graph, timeline, observations, and HTML reports. Data is stored in SQLite (migrations via Alembic on startup). Demo data is available from the overview when the database is empty.
mailtrace analyze suspicious.eml
mailtrace analyze suspicious.eml --format json
mailtrace analyze suspicious.eml --output report.md
mailtrace analyze suspicious.eml --extract-attachments ./attachments
mailtrace ioc suspicious.eml --format jsonanalyze prints a terminal summary. --format accepts text, json, markdown, or html. If you pass --output and omit --format, the suffix chooses the format (.md, .json, .html, .txt). An explicit --format wins. JSON is written to stdout only when you do not pass --output.
--fail-on info|low|medium|high exits with code 4 when any observation is at that severity or higher. The analysis itself still completes.
ioc prints deduplicated indicators. The default format is soc: a tab-separated list for handoff. The value, and any URL in the context column, are defanged (hxxps, dots as [.], IPv6 colons as [:]) so the lines are not links. json and csv keep the original indicator values. Attachment hashes are not rewritten.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | The command finished. |
| 2 | The file, format, or config could not be used, or dns-policy was started without --confirm-network. |
| 4 | --fail-on matched an observation. |
| 1 | An unexpected error. |
Use - as the path to read a message from stdin. mailtrace --help and mailtrace analyze --help describe the flags.
Optional limits live in a TOML file. See examples/mailtrace.toml. The file can lower or raise limits inside hard ceilings. It cannot enable network access.
mailtrace analyze suspicious.eml --config examples/mailtrace.tomlThis report was produced locally with:
mailtrace analyze tests/fixtures/suspicious_reply_to.eml --format markdownThe same text is in examples/demo-report.md. The Generated timestamp is the time of that run. Re-running the command refreshes it. The attention score is 20 because the display-name mismatch is weighted higher than the Reply-To and Return-Path differences. The missing authentication header adds no points.
The fixture is a synthetic message: the visible From name contains security@paypal.com, the address is attacker@evil.example, Reply-To is phish@collector.example, and Return-Path is bounces@other.example.
# MailTrace report
Local triage only. MailTrace did not contact remote hosts or open attachments. This is not a verdict.
- Source: `suspicious_reply_to.eml`
- Generated: `2026-10-20T11:06:57.532744+00:00`
- Attention score: 20/100
- The attention score ranks how much review the recorded observations suggest. It is not a probability and not a verdict.
## Message
- Subject: `Mailbox storage`
- From: `PayPal <security@paypal.com> <attacker@evil.example>`
- Reply-To: `phish@collector.example`
- Return-Path: `bounces@other.example`
- To: `Bob Example <bob@example.com>`
- Date: `2024-10-15T11:00:00+00:00`
- Message-ID: `<rt-001@evil.example>`
- MIME parts analyzed: 1
## Observations
### low / low / inconsistency — Reply-To domain differs from From
`header.reply_to_domain`
A Reply-To domain is not the From domain or a subdomain of it. Mailing lists, shared mailboxes, and third-party senders do this often. It is a lead for the analyst, not evidence that the message is harmful.
### medium / medium / inconsistency — Display name contains a different address
`header.display_name_address`
The visible From name contains an email address that is not the actual address. Recipients often read the name and skip the address. This is a stronger lead than a Reply-To difference, and it is still not proof.
### info / high / absence — No recorded SPF, DKIM, or DMARC result
`auth.missing_results`
No Authentication-Results or Received-SPF header was found. Absence of these headers is not evidence of phishing.
## Limitations
MailTrace did not independently verify SPF, DKIM, or DMARC, did not connect to any URL, did not open attachments, and did not render HTML in a browser. Observations are heuristics, not a verdict.
The full export also lists Return-Path, indicators, score contributions, and analyst recommendations.
Each observation has:
- Severity (
info,low,medium,high): how much handling attention the artifact suggests. - Confidence (
low,medium,high): how stable the signal is. Clock skew and forwarding stay at low confidence. - Kind:
artifact(something is present),absence(a header was not present), orinconsistency(two fields disagree). - Evidence: the header, URL, or attachment field the rule used. Message bodies, script bodies, and archive members are not copied into the report.
The attention score sums weighted observations and stops at 100. Info observations add nothing. The score is not a probability and not a verdict.
HTML output escapes message text, sends Content-Security-Policy: default-src 'none', and does not turn extracted URLs into links.
Rules live in mailtrace.rules and are registered in one list. Pass another RuleSet to analyze_bytes to replace or extend them. Built-in rules:
| Rule | Usual severity | What it records |
|---|---|---|
header.reply_to_domain |
low, or info if the domain is a parent or subdomain | Reply-To is not the From domain. Not proof of harm. |
header.return_path_domain |
low, or info when related | Return-Path domain differs from From. Common for bulk mail and forwarding. |
header.display_name_address |
medium | The From display name contains a different email address. |
header.message_id_domain |
info | Message-ID domain is unrelated to From. Often just the sending host. |
header.received_time_inversion |
low | A nearer Received hop is dated more than 24 hours earlier than the hop below it. |
header.invisible_characters |
medium for bidi controls, otherwise low | Bidi or zero-width characters were removed from displayed text. |
auth.recorded_failure |
medium for fail, low for softfail and errors |
A header records a non-pass SPF, DKIM, or DMARC result. MailTrace did not repeat the check. |
auth.missing_results |
info | No Authentication-Results or Received-SPF header. Not evidence of phishing. |
auth.conflicting_results |
medium | The same method has more than one recorded result. Compare authserv-id with the gateway you trust. |
auth.alignment |
low | A recorded DKIM or SPF domain does not match From, and that scope has no recorded dmarc=pass. Not an independent DMARC evaluation. |
auth.signature_unverified |
info | A DKIM-Signature header is present and no recorded DKIM result was found. The signature was not checked. |
url.ip_literal |
low | A URL host is an IP address. |
url.obfuscation |
medium or low | Userinfo, an encoded IP, a backslash, odd percent-encoding, or whitespace. A scheme-relative URL alone does not match. |
url.idn |
medium when scripts mix or characters are confusable, otherwise info | Punycode or mixed-script host. There is no brand list. |
url.href_mismatch |
medium | Visible link text looks like a URL whose host is not the href host. |
url.redirect_parameter |
low | A query parameter looks like a redirect target. It is decoded locally and not fetched. |
url.dangerous_scheme |
medium | javascript:, data:, or vbscript:. |
url.suspicious_host |
low or info | Many labels, or a keyword such as login under the registrable-looking tail. No public-suffix list. |
attachment.mime_mismatch |
medium | Extension and a specific MIME type disagree. application/octet-stream is not a mismatch. |
attachment.dangerous_type |
medium, or low for HTML-only | Executable, macro, disk-image, or HTML attachment type. The bytes were not run. |
attachment.archive_not_inspected |
low | Name, type, or file header looks like an archive. It was not unpacked. |
attachment.double_extension |
high when the final extension can carry active content, otherwise medium | A document-style extension is followed by another extension. |
attachment.content_mismatch |
high when the header looks executable, otherwise medium | The first bytes do not match the extension. Only the prefix is inspected. |
html.script |
medium | A script tag is present. The script body is not copied. |
html.event_handler |
low | An event-handler attribute is present. Its value is not copied. |
html.active_content |
low | A form, iframe, object, or meta refresh is present. |
html.hidden_content |
info | CSS hides content. Weak context; marketing mail does this often. |
structure.parse_defects |
low | The parser recorded MIME or header defects. |
structure.nested_message |
info | A nested message was parsed, within the depth limit. |
High severity marks a handling fact, such as a double extension. It does not mean the bytes were shown to be harmful.
Indicators are domains, email addresses, IP addresses, URLs, and SHA-256 hashes. Each one records where it was seen: a header, the body text, or an attachment. Values are deduplicated. URL indicators keep their scheme and are not lowercased as a group with the other types; domains and addresses are stored in lowercase. The SOC export defangs URL, domain, address, and IP values at export time, including URLs that appear in the context column. IPv6 colons are rewritten as [:]. JSON and CSV keep the original values.
Published SPF, DMARC, and DKIM TXT records are a separate command and require an explicit flag:
mailtrace dns-policy suspicious.eml --confirm-network
mailtrace dns-policy suspicious.eml --confirm-network --format jsonWithout --confirm-network the command exits 2 and does not query. Lookups use the system resolver through dnspython, only for sender-authentication names, and they do not evaluate the policy. The names are disclosed to whatever resolver the host uses. URL hosts are not queried.
Defaults, which a config file may change up to a hard ceiling:
| Limit | Default |
|---|---|
| Message size | 25 MB (ceiling 100 MB) |
| MIME parts | 300 |
| MIME depth | 30 |
| Nested messages | 5 |
| Attachment size | 20 MB |
| URLs | 1,000 |
| Header value length | 100,000 characters |
| Headers stored | 500 |
| Text per part | 2,000,000 characters |
- Recorded authentication results are not an independent check. A sender can prepend a fake
Authentication-Resultsheader. Compare theauthserv-idwith the gateway you trust. - URLs are not contacted, so redirects, URL shorteners, and live pages are not followed beyond one locally decoded redirect parameter.
- Archives, disk images, and nested files inside them are not opened.
- HTML is not rendered, so script that depends on a browser does not run, and some obfuscation that a browser would decode can be missed.
- Host checks do not use a public-suffix list or a brand dictionary.
- Content in images, encrypted parts, and unknown encodings may be invisible to the parser.
- Bidi and zero-width characters are stripped from displayed fields after they are recorded.
Synthetic fixtures in tests/fixtures/ cover mail parsing and rules; tests/infrachronicle/ covers providers, API, and infra UI routes. Tests do not use the network except where explicitly mocked.
ruff check .
ruff format --check .
mypy src
pytestGitHub Actions runs those commands on Python 3.12 and 3.13.
src/mailtrace/ CLI, parser, rules, reports, /email web UI
src/mailtrace/platform/ Tracework hub and FastAPI assembly
src/infrachronicle/ Providers, SQLite models, /api, /infra UI
alembic/ InfraChronicle schema migrations
Shared styling: src/infrachronicle/static/css/workbench.css (served as /static/css/workbench.css). Mail-specific assets: /static/mail/.
Network paths: optional mailtrace dns-policy (explicit flag) and InfraChronicle collectors (allowlisted HTTP/DNS only).
See SECURITY.md for MailTrace handling rules and web exposure notes.