Stateless HTTP API that parses raw emails with mail-parser and returns the parsed message as JSON. It powers the live parser on spamscope.ai.
Nothing is stored: each email is parsed in memory, in a short-lived subprocess, and only the JSON goes back to the caller.
Send the raw email as the request body with Content-Type: message/rfc822.
curl -s -H 'Content-Type: message/rfc822' --data-binary @email.eml \
http://localhost:8000/api/parseResponse 200:
{
"mail_parser_version": "4.10.0",
"message": {"subject": "...", "attachments": [{"filename": "...", "binary": true}]}
}message is the full mail_json of mail-parser. Every entry of attachments
keeps its metadata and loses the payload key.
| Status | When | Body |
|---|---|---|
413 |
Body larger than 5 MB | {"detail": "Body exceeds 5 MB"} |
415 |
Content-Type is not message/rfc822 |
{"detail": "Content-Type must be message/rfc822"} |
415 |
Content-Type is application/vnd.ms-outlook |
{"detail": "Outlook .msg not supported yet"} |
422 |
mail-parser fails on the email | {"detail": "<mail-parser error message>"} |
429 |
More than 10 requests in 60 seconds from the same client key | {"detail": "Too many requests"} |
504 |
Parsing takes longer than 10 seconds | {"detail": "Parsing timed out"} |
mail-parser is tolerant: most malformed emails still return 200, with
has_defects and defects_categories describing the problems.
Returns 200 with {"status": "ok"}. It is meant for container health checks;
the reverse proxy does not expose it.
- Each request runs the installed mail-parser CLI (
mail-parser --stdin --json) in a subprocess. The email goes to its stdin, never through a shell; the subprocess is killed after 10 seconds. - The request body is limited to 5 MB.
- At most 2 emails are parsed at once; further requests wait for a free slot.
- In the container each parse is limited to 192 MiB of address space
(
prlimit) and is the first process the kernel kills when memory runs out (choom), so the server survives. Such an email gets422withemail too complex to parse within the service limits. - Rate limit: 10 requests per 60-second sliding window per client key, in
memory, for at most 10 000 keys (least recently used evicted first). The
client key is the first
X-Forwarded-Forvalue, or the peer address without it: an IPv4 address whole, an IPv6 address collapsed to its/64. The header is trusted because the service runs only behind a reverse proxy that sets it. - uvicorn runs one worker with
--limit-concurrency 12, above the parse slots so that health checks still get through. - The access log has only method, path, status and response size. Request bodies and email headers are never logged.
- The container image runs as a non-root user and works with a read-only root
filesystem, all capabilities dropped and a
tmpfson/tmp.
Requirements: uv and, for the container, Docker.
make install
uv run uvicorn mail_parser_api.app:create_app --factory --port 8000With Docker Compose, using the same hardening as production:
make up # build and start on http://localhost:8000
make downmake pre-commit # ruff, ruff-format, pyright, mdformat, markdownlint, ...
make test-cov # tests with coverageSee CLAUDE.md for the coding rules.
Every pull request to main needs exactly one release label: major_release,
minor_release or patch_release. CI checks the label, then runs lint, tests
and a linux/amd64 image build without pushing (lint-test-build, required
before merge), so a broken lockfile or Dockerfile fails before a tag exists.
When the pull request is squash-merged, the release workflow
(pull_request_target, so merged fork pull requests release too; it never
checks out pull request code) runs one release at a time and:
- bumps
__version__insrc/mail_parser_api/__init__.pyaccording to the label and tagsvX.Y.Z, pushed with theRELEASE_DEPLOY_KEYdeploy key, a secret of thereleaseenvironment that onlymaincan use; - publishes
ghcr.io/spamscope/mail-parser-api:X.Y.Zand:latestforlinux/amd64andlinux/arm64; - creates the GitHub release with generated notes.
Two rulesets protect main. main requires a pull request and the
lint-test-build check; the deploy key is its only bypass actor, so the bump
commit can land without a pull request. main-protect blocks deletion and
force pushes with no bypass, not even for the deploy key.
Dependabot opens weekly pull requests, labelled patch_release, for production
dependencies and GitHub Actions, so a new mail-parser on PyPI becomes a new
release and a new image.