(mta-in) rewrite MTA-in in pure Python to remove dep on Postfix (#692)

Postfix was already removed as a mta-out dependency, this is the second step so we have a pure python, more auditable path for incoming emails. We plan to keep postfix as a compatible option for a while but it won't be the default once this is battle tested.
This commit is contained in:
Sylvain Zimmer
2026-07-06 16:20:51 +02:00
committed by jbpenrath
parent a1ffcf3673
commit 632f38da2d
29 changed files with 3478 additions and 68 deletions
+26 -3
View File
@@ -60,6 +60,7 @@ create-env-files: \
env.d/development/backend.local \ env.d/development/backend.local \
env.d/development/frontend.local \ env.d/development/frontend.local \
env.d/development/mta-in.local \ env.d/development/mta-in.local \
env.d/development/mta-in-py.local \
env.d/development/mta-out.local \ env.d/development/mta-out.local \
env.d/development/socks-proxy.local env.d/development/socks-proxy.local
.PHONY: create-env-files .PHONY: create-env-files
@@ -127,6 +128,17 @@ test-back-distroless: build-back-distroless ## build and smoke-test the distrole
print(f'OK: Python {sys.version.split()[0]}, {ssl.OPENSSL_VERSION}')" print(f'OK: Python {sys.version.split()[0]}, {ssl.OPENSSL_VERSION}')"
.PHONY: test-back-distroless .PHONY: test-back-distroless
build-pymta-distroless: ## build the pymta distroless production image
@docker build --target runtime-distroless-prod -t messages-pymta-distroless -f src/mta-in/Dockerfile.pymta src/mta-in/
.PHONY: build-pymta-distroless
test-pymta-distroless: build-pymta-distroless ## build and smoke-test the pymta distroless production image
@docker run --rm messages-pymta-distroless python -c " \
import sys, ssl; \
import pymta.settings; \
print(f'OK: Python {sys.version.split()[0]}, {ssl.OPENSSL_VERSION}, pymta.settings loaded')"
.PHONY: test-pymta-distroless
down: ## stop and remove containers, networks, images, and volumes down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down @$(COMPOSE) down
.PHONY: down .PHONY: down
@@ -181,6 +193,7 @@ lint: \
lint-front \ lint-front \
typecheck-front \ typecheck-front \
lint-mta-in \ lint-mta-in \
lint-mta-in-py \
lint-mta-out lint-mta-out
.PHONY: lint .PHONY: lint
@@ -228,12 +241,17 @@ lint-front: ## run the frontend linter
@$(COMPOSE) run --rm frontend-tools npm run lint @$(COMPOSE) run --rm frontend-tools npm run lint
.PHONY: lint-front .PHONY: lint-front
lint-mta-in: ## lint mta-in python sources lint-mta-in: ## lint mta-in python sources (Postfix milter implementation)
$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-test ruff format . $(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-test ruff format .
#$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-test ruff check . --fix #$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-test ruff check . --fix
#$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-test pylint . #$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-test pylint .
.PHONY: lint-mta-in .PHONY: lint-mta-in
lint-mta-in-py: ## lint mta-in python sources (pure-Python pymta implementation)
$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-py-test ruff format .
$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-in-py-test ruff check . --fix
.PHONY: lint-mta-in-py
lint-mta-out: ## lint mta-out python sources lint-mta-out: ## lint mta-out python sources
$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-out-test ruff format . $(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-out-test ruff format .
.PHONY: lint-mta-out .PHONY: lint-mta-out
@@ -245,6 +263,7 @@ test: \
test-back \ test-back \
test-front \ test-front \
test-mta-in \ test-mta-in \
test-mta-in-py \
test-mta-out \ test-mta-out \
test-mpa \ test-mpa \
test-socks-proxy test-socks-proxy
@@ -285,10 +304,14 @@ test-front-amd64: ## run the frontend tests in amd64
$(COMPOSE) run --rm frontend-tools-amd64 npm run test -- $${args:-${1}} $(COMPOSE) run --rm frontend-tools-amd64 npm run test -- $${args:-${1}}
.PHONY: test-front-amd64 .PHONY: test-front-amd64
test-mta-in: ## run the mta-in tests test-mta-in: ## run the mta-in tests against the Postfix milter implementation
@$(COMPOSE) run --build --rm mta-in-test @$(COMPOSE) run --build --rm mta-in-test
.PHONY: test-mta-in .PHONY: test-mta-in
test-mta-in-py: ## run the mta-in tests against the pure-Python (aiosmtpd) implementation
@$(COMPOSE) run --build --rm mta-in-py-test
.PHONY: test-mta-in-py
test-mta-out: ## run the mta-out tests test-mta-out: ## run the mta-out tests
@$(COMPOSE) run --build --rm mta-out-test @$(COMPOSE) run --build --rm mta-out-test
.PHONY: test-mta-out .PHONY: test-mta-out
@@ -630,7 +653,7 @@ test-keycloak: ## run all Keycloak provider tests (builds JARs, brings up Keyclo
@bin/test-keycloak @bin/test-keycloak
.PHONY: test-keycloak .PHONY: test-keycloak
deps-lock-mta-in: ## lock the dependencies deps-lock-mta-in: ## lock the dependencies for mta-in (shared between both implementations)
@$(COMPOSE) run --rm --build mta-in-uv uv lock @$(COMPOSE) run --rm --build mta-in-uv uv lock
.PHONY: deps-lock-mta-in .PHONY: deps-lock-mta-in
+67
View File
@@ -263,6 +263,8 @@ services:
- EXEC_CMD=true - EXEC_CMD=true
- MDA_API_BASE_URL=http://localhost:8000/api/mail/ - MDA_API_BASE_URL=http://localhost:8000/api/mail/
- MTA_HOST=localhost - MTA_HOST=localhost
- MTA_PORT=25
- MTA_IMPL=postfix
command: pytest -vvs tests/ command: pytest -vvs tests/
volumes: volumes:
- ./src/mta-in:/app - ./src/mta-in:/app
@@ -277,6 +279,71 @@ services:
target: uv target: uv
pull_policy: build pull_policy: build
# ---- Pure-Python (aiosmtpd) inbound MTA --------------------------------
# Runs side-by-side with the Postfix-based `mta-in` service on a different
# host port (8920 vs 8910). Both implementations share the same MDA
# contract, env vars, and test suite. Toggle which one is the public-facing
# MTA at the edge by switching the upstream pool.
mta-in-py:
build:
context: src/mta-in
dockerfile: Dockerfile.pymta
target: runtime-distroless-prod
args:
DOCKER_USER: ${DOCKER_USER:-65532}
user: ${DOCKER_USER:-65532}
env_file:
- env.d/development/mta-in.defaults
- env.d/development/mta-in.local
- env.d/development/mta-in-py.defaults
- env.d/development/mta-in-py.local
ports:
- "8920:25"
- "9120:9100" # Prometheus metrics
# Defence-in-depth: pymta needs no on-disk writes at runtime. Read-only
# rootfs + dropped capabilities + no-new-privileges mirror the posture
# a production k8s pod-spec should run with.
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
depends_on:
- backend-dev
mta-in-py-test:
profiles:
- tools
build:
context: src/mta-in
dockerfile: Dockerfile.pymta
target: runtime-dev
args:
DOCKER_USER: ${DOCKER_USER:-65532}
user: ${DOCKER_USER:-65532}
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file:
- env.d/development/mta-in.defaults
- env.d/development/mta-in.local
- env.d/development/mta-in-py.defaults
- env.d/development/mta-in-py.local
environment:
- EXEC_CMD=true
- MDA_API_BASE_URL=http://localhost:8000/api/mail/
- MTA_HOST=localhost
- MTA_PORT=25
- MTA_IMPL=pymta
- MTA_METRICS_URL=http://localhost:9100/metrics
command: pytest -vvs tests/
volumes:
- ./src/mta-in:/app
mta-out: mta-out:
build: build:
context: src/mta-out context: src/mta-out
+34
View File
@@ -0,0 +1,34 @@
# Defaults for the pure-Python (aiosmtpd) inbound MTA. The Postfix-based
# `mta-in` service does not consume these.
# PYMTA_HOSTNAME is intentionally left unset so the shared MYHOSTNAME env
# wins (matching the Postfix variant). Set explicitly to override.
PYMTA_SMTP_HOST=0.0.0.0
PYMTA_SMTP_PORT=25
PYMTA_METRICS_HOST=0.0.0.0
PYMTA_METRICS_PORT=9100
PYMTA_LOG_LEVEL=INFO
# Match the existing Postfix dev config: per-IP cap off (the dev/test load
# all comes from loopback), large global cap for `test_connection_limits`.
PYMTA_MAX_SESSIONS_PER_IP=0
PYMTA_MAX_SESSIONS_TOTAL=2000
# Inbound limits.
PYMTA_MAX_RECIPIENTS=100
PYMTA_MAX_ENVELOPES_PER_CONNECTION=20
# Timeouts (seconds).
PYMTA_COMMAND_TIMEOUT=120
PYMTA_DATA_TIMEOUT=600
# STARTTLS off by default in dev (no cert wired).
PYMTA_TLS_CERT_FILE=
PYMTA_TLS_KEY_FILE=
# SMTPUTF8 advertised — the MDA accepts UTF-8 envelope addresses.
PYMTA_ENABLE_SMTPUTF8=true
# PROXY protocol off in dev. In production set ENABLE_PROXY_PROTOCOL=haproxy
# (same env var the Postfix entrypoint already consumes) when behind HAProxy.
PYMTA_ENABLE_PROXY_PROTOCOL=false
+4 -2
View File
@@ -37,8 +37,10 @@ COPY pyproject.toml uv.lock ./
ENV PATH="/venv/bin:$PATH" ENV PATH="/venv/bin:$PATH"
# Install dependencies # Install dependencies. The postfix extra pulls in pymilter (C extension needing
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-install-project --no-dev # libmilter-dev at install time); it lives behind an extra so the pure-Python
# pymta image can build without libmilter-dev.
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-install-project --no-dev --extra postfix
# ---- Base image with dependencies installed for development ---- # ---- Base image with dependencies installed for development ----
FROM base-with-deps AS base-with-deps-dev FROM base-with-deps AS base-with-deps-dev
+181
View File
@@ -0,0 +1,181 @@
# Pure-Python inbound MTA image (aiosmtpd) — counterpart to ./Dockerfile
# (Postfix + milter). Both images share src/mta-in/pyproject.toml and live in
# parallel so the two implementations can be tested side-by-side. The Postfix
# image is the production default for now; this image will gradually replace
# it once parity is proven.
#
# Image layout mirrors src/backend/Dockerfile:
# * debian:trixie-slim base (not python:slim — Python comes from uv).
# * uv pinned by SHA256 digest for supply chain integrity.
# * uv-managed python-build-standalone (most C deps statically linked).
# * `python-runtime` stage strips pip/idle/tkinter/headers.
# * `runtime-prod` (slim) and `runtime-distroless-prod` (cc-debian13:nonroot)
# both available; the distroless variant is the security target.
#
# ---- Base OS ----
FROM debian:trixie-slim AS base
# Bump this to force an update of the apt repositories
ENV MIN_UPDATE_DATE="2026-06-05"
RUN <<EOR
apt-get update
DEBIAN_FRONTEND="noninteractive" apt-get upgrade -y
DEBIAN_FRONTEND="noninteractive" apt-get install -y --no-install-recommends \
ca-certificates
rm -rf /var/lib/apt/lists/*
EOR
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# ---- uv + managed Python + build system deps ----
FROM base AS uv
# Pin uv by SHA256 digest for supply chain security. We pull a newer uv than
# src/backend/Dockerfile (0.11.10): uv 0.11.10 only knows Python 3.14.5rc1,
# and python-build-standalone shipped 3.14.5 final on 2026-05-10 — which
# only landed in uv from 0.11.16 onward.
# Verify with: gh attestation verify --owner astral-sh oci://ghcr.io/astral-sh/uv:0.11.19
COPY --from=ghcr.io/astral-sh/uv@sha256:b46b03ddfcfbf8f547af7e9eaefdf8a39c8cebcba7c98858d3162bd28cf536f6 /uv /uvx /bin/
RUN <<EOR
apt-get update
DEBIAN_FRONTEND="noninteractive" apt-get install -y --no-install-recommends \
ca-certificates \
build-essential
rm -rf /var/lib/apt/lists/*
EOR
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
ENV UV_PYTHON_PREFERENCE=only-managed
ENV UV_PYTHON_INSTALL_DIR=/opt/python
ENV UV_PROJECT_ENVIRONMENT=/venv
# Install Python via uv — integrity verified against SHA256 checksums embedded in the uv binary.
# Uses python-build-standalone: most C deps (ssl, ffi, sqlite, zlib, lzma, bz2) are statically linked.
RUN uv python install 3.14.5
# ---- Production dependencies ----
FROM uv AS base-with-deps
COPY pyproject.toml uv.lock ./
ENV PATH="/venv/bin:$PATH"
# No extras: the `postfix` extra (pymilter) needs libmilter-dev which is not
# present here, and we don't want it anyway.
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-install-project --no-editable --no-dev
# ---- Development dependencies ----
FROM base-with-deps AS base-with-deps-dev
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-install-project --no-editable --extra dev
# ---- Strip Python for production (remove pip, idle, tkinter, tcl, headers, tests) ----
FROM uv AS python-runtime
RUN <<EOR
set -e
PYDIR=$(dirname $(dirname $(uv python find 3.14.5)))
rm -rf \
$PYDIR/bin/idle* $PYDIR/bin/pip* $PYDIR/bin/pydoc* $PYDIR/bin/*-config \
$PYDIR/include $PYDIR/share \
$PYDIR/lib/pkgconfig $PYDIR/lib/itcl* $PYDIR/lib/libtcl* \
$PYDIR/lib/tcl* $PYDIR/lib/tk* $PYDIR/lib/thread* \
$PYDIR/lib/python3.14/idlelib \
$PYDIR/lib/python3.14/ensurepip \
$PYDIR/lib/python3.14/tkinter \
$PYDIR/lib/python3.14/turtledemo \
$PYDIR/lib/python3.14/lib-dynload/_tkinter* \
$PYDIR/lib/python3.14/lib-dynload/_ctypes_test* \
/opt/python/.gitignore /opt/python/.lock /opt/python/.temp
EOR
# ---- Base runtime image (slim) ----
FROM base AS runtime-base
# Give the "root" group the same permissions as the "root" user on /etc/passwd
# to allow a user belonging to the root group to add new users; mirrors the
# backend pattern so DOCKER_USER bind-mount UIDs work in dev.
RUN chmod g=u /etc/passwd
# Un-privileged user running the application.
ARG DOCKER_USER=65532
USER ${DOCKER_USER}
ENV PATH="/venv/bin:$PATH"
ENV VIRTUAL_ENV=/venv
ENV VIRTUAL_ENV_PROMPT=venv
COPY ./entrypoint.pymta.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ]
# ---- Development runtime ----
FROM runtime-base AS runtime-dev
# Full Python installation (with headers, pip — useful for debugging)
COPY --from=uv /opt/python /opt/python
COPY --from=base-with-deps-dev /venv /venv
ENV PYTHONPATH="/app/src"
# /app will be mounted as a volume in the development container
# ---- Production application source (strip tests, dev tooling, build files) ----
FROM base AS app-prod
COPY ./src /app/src
# ---- Production runtime (slim) ----
FROM runtime-base AS runtime-prod
COPY --from=python-runtime /opt/python /opt/python
COPY --from=base-with-deps /venv /venv
COPY --from=app-prod /app/src /app/src
ENV PYTHONPATH="/app/src"
CMD ["python", "-m", "pymta.server"]
# Liveness probe: TCP-connect the SMTP listener. Uses stdlib socket — works in
# both slim and distroless variants without curl/nc.
HEALTHCHECK --interval=30s --timeout=2s --start-period=15s \
CMD ["python", "-c", "import os, socket; s=socket.socket(); s.settimeout(1); s.connect(('127.0.0.1', int(os.getenv('PYMTA_SMTP_PORT', '25')))); s.close()"]
# ---- Distroless production runtime ----
# Uses cc-debian13 (C runtime only) + python-build-standalone from uv. No
# shell, no package manager, no busybox utilities. Runs as uid 65532.
# Debug with: docker run --entrypoint='' gcr.io/distroless/cc-debian13:debug-nonroot sh
FROM gcr.io/distroless/cc-debian13:nonroot AS runtime-distroless-prod
WORKDIR /app
# Stripped Python installation (python-build-standalone via uv)
COPY --from=python-runtime /opt/python /opt/python
# Python dependencies
COPY --from=base-with-deps /venv /venv
# Application code
COPY --from=app-prod /app/src /app/src
ENV PATH="/venv/bin:$PATH"
ENV VIRTUAL_ENV=/venv
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONPATH="/app/src"
# No shell entrypoint — distroless has no /bin/sh. The shell wrapper
# (entrypoint.pymta.sh) is only for dev-time EXEC_CMD modes.
CMD ["python", "-m", "pymta.server"]
HEALTHCHECK --interval=30s --timeout=2s --start-period=15s \
CMD ["python", "-c", "import os, socket; s=socket.socket(); s.settimeout(1); s.connect(('127.0.0.1', int(os.getenv('PYMTA_SMTP_PORT', '25')))); s.close()"]
+72 -16
View File
@@ -4,27 +4,83 @@ The MTA is in charge of receiving emails from the Internet and pushing them to t
It only deals with inbound email and won't even send bounces by itself. It only deals with inbound email and won't even send bounces by itself.
This MTA container is based on standard technologies such as Postfix with a custom Python milter, and is entirely stateless. It is entirely configurable from env vars. The MTA is entirely stateless and configured from env vars.
It is battle-tested with a complete Python test suite. ## Two implementations, same contract
After receiving an email through SMTP, it processes each message synchronously during the SMTP session using a custom Postfix milter that: This directory ships **two** implementations in parallel. Both expose the same SMTP behaviour to the outside world and speak the same MDA REST contract:
- Validates each recipient with a REST API call to `{env.MDA_API_BASE_URL}/inbound/mta/check/` during the RCPT TO command
- Delivers the complete message via REST API call to `{env.MDA_API_BASE_URL}/inbound/mta/deliver/` during the DATA command
- Either accepts (discards from queue) or rejects the SMTP session based on delivery results
This architecture ensures true synchronous delivery - delivery failures cause immediate SMTP session rejection, and successful deliveries prevent the message from entering the Postfix queue. | | Postfix + milter (default) | pymta (pure-Python) |
|---|---|---|
| Compose service | `mta-in` (host port `8910`) | `mta-in-py` (host port `8920`) |
| Image | `Dockerfile` | `Dockerfile.pymta` |
| SMTP server | Postfix `smtpd` | `aiosmtpd 1.4.6` |
| MDA glue | `src/delivery_milter.py` + `src/api/mda.py` (sync `requests`) | `src/pymta/*` (async `httpx`) |
| Prometheus metrics | — | `/metrics` on port `9100` |
| Tests | `make test-mta-in` | `make test-mta-in-py` |
| Lint | `make lint-mta-in` | `make lint-mta-in-py` |
The API calls are secured by a JWT token, using a shared secret `env.MDA_API_SECRET`. Both run as a stateless, queue-less SMTP front-end. After receiving an email through SMTP each message is processed synchronously during the SMTP session by:
To run the tests, go to the repository root and do: - Validating each recipient with a REST API call to `{env.MDA_API_BASE_URL}/inbound/mta/check/` during the RCPT TO command.
- Delivering the complete message via REST API call to `{env.MDA_API_BASE_URL}/inbound/mta/deliver/` during the DATA command.
- Translating the MDA outcome (200 + `status=ok` / 5xx / timeout) into a single SMTP reply line.
``` ### MDA wire contract
Each MTA → MDA call is an HTTP `POST` carrying:
- **Body** — for `check/`, an `application/json` document `{"addresses": [...]}`; for `deliver/`, the full RFC 5322 message as `message/rfc822`.
- **Authorization** — `Bearer <jwt>` where the JWT is signed HS256 with `env.MDA_API_SECRET` and carries:
- `exp`: 60 s from issuance, anchored in UTC.
- `body_hash`: `sha256(body).hexdigest()` — binds the token to the exact bytes posted (replay-proof per-request).
- Plus, for `deliver/`, envelope metadata claims (`sender`, `original_recipients`, `client_address`, `client_port`, `client_hostname`, `client_helo`, `size`).
- **Response** — `200 OK` + JSON for success; `4xx` for permanent reject; `5xx` for tempfail. Timeouts and transport errors are tempfail too.
In production, run pymta with `MDA_API_BASE_URL=https://...` so the bearer token doesn't traverse the network in clear. The client logs a `WARNING` at startup if a non-local `http://` URL is configured.
## When to use which
Postfix is the production default. The pymta implementation is offered side-by-side so it can take over once parity is proven; it is easier to extend (no milter protocol, no C glue), gives us Prometheus metrics, and reduces the attack surface (no Postfix binary, no `libmilter`, no on-disk queue at all).
Switching production from one to the other only requires re-pointing the inbound public IP to the other container — the MDA back-end and the env vars are identical. PROXY-protocol passthrough is toggled by the same `ENABLE_PROXY_PROTOCOL=haproxy` env var on both.
## Running
```bash
# Default Postfix-based service
make test-mta-in make test-mta-in
```
You should also run lint before commit:
```
make lint-mta-in make lint-mta-in
```
# Pure-Python (aiosmtpd) service
make test-mta-in-py
make lint-mta-in-py
```
The shared test suite under `tests/` runs against both via the `MTA_HOST` / `MTA_PORT` env vars. A few tests assert implementation-specific behaviour and skip on the other impl (e.g. `tests/test_metrics.py` is pymta-only; the strict NUL-byte rejection test skips on Postfix). The fixtures `mta_impl`, `mta_address`, and `mta_metrics_url` in `tests/conftest.py` are how a test sees which impl it is running against.
## pymta-specific env vars
In addition to the shared `MDA_API_BASE_URL` / `MDA_API_SECRET` / `MDA_API_TIMEOUT` / `MAX_INCOMING_EMAIL_SIZE`, the pymta image reads:
| Variable | Default | Purpose |
|---|---|---|
| `PYMTA_HOSTNAME` | `mta-in` | Banner / Received-header host name |
| `PYMTA_SMTP_HOST` / `PYMTA_SMTP_PORT` | `0.0.0.0` / `25` | SMTP listener bind |
| `PYMTA_METRICS_HOST` / `PYMTA_METRICS_PORT` | `0.0.0.0` / `9100` | Prometheus endpoint (set port to 0 to disable) |
| `PYMTA_MAX_RECIPIENTS` | `100` | RCPT TO cap per envelope |
| `PYMTA_MAX_ENVELOPES_PER_CONNECTION` | `10` | Envelopes per TCP session |
| `PYMTA_HARD_ERROR_LIMIT` | `50` | 4xx/5xx replies before forcing 421 + disconnect |
| `PYMTA_MAX_RCPT_MISSES_PER_SESSION` | `10` | Unknown-mailbox lookups before 421 + disconnect |
| `PYMTA_MAX_SESSIONS_PER_IP` | `100` (0 = off) | Per-IP concurrent session cap |
| `PYMTA_MAX_SESSIONS_PER_IP_PER_MINUTE` | `600` (0 = off) | Per-IP new-session rate cap (rolling 60 s window) |
| `PYMTA_MAX_SESSIONS_TOTAL` | `1000` (0 = off) | Process-wide concurrent session cap |
| `PYMTA_COMMAND_TIMEOUT` | `120` | Per-command idle timeout (s) |
| `PYMTA_DATA_TIMEOUT` | `600` | Total DATA-phase deadline (s) |
| `PYMTA_SHUTDOWN_TIMEOUT` | `25` | Drain deadline on SIGTERM before abandoning in-flight sessions (s) |
| `PYMTA_MDA_BREAKER_THRESHOLD` | `10` (0 = off) | Consecutive MDA failures before short-circuiting to 451 |
| `PYMTA_MDA_BREAKER_COOLDOWN` | `30` | Seconds the breaker stays open before probing the MDA again |
| `PYMTA_TLS_CERT_FILE` / `PYMTA_TLS_KEY_FILE` | empty | STARTTLS cert + key paths (empty = STARTTLS off) |
| `STARTTLS_CHAIN_FILES` | empty | Postfix-compatible fallback — comma-separated PEM bundle(s); first bundle wins when `PYMTA_TLS_*` is unset |
| `PYMTA_ENABLE_SMTPUTF8` | `true` | Advertise SMTPUTF8 in EHLO |
| `ENABLE_PROXY_PROTOCOL` | unset | Set to `haproxy` to enable PROXY-protocol v1/v2 |
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
# Entrypoint for the pure-Python (aiosmtpd) inbound MTA image.
#
# Two modes:
# - EXEC_CMD_ONLY=true: skip starting the server, exec the user command. Used
# by ad-hoc tooling like `ruff format .`.
# - EXEC_CMD=true: start the server in the background, then exec the user
# command. Used by the test runner to colocate pytest + pymta in a single
# container (mirroring the postfix mta-in-test workflow).
# - default: exec the server in the foreground.
set -eu
if [ "${EXEC_CMD_ONLY:-false}" = "true" ]; then
exec "$@"
fi
start_pymta() {
python -m pymta.server &
PYMTA_PID=$!
# Wait until the SMTP port accepts connections (max ~15s). Uses stdlib
# socket rather than nc so the runtime image doesn't need netcat just
# for this probe.
port="${PYMTA_SMTP_PORT:-25}"
for i in $(seq 1 30); do
if python -c "import socket, sys; s=socket.socket(); s.settimeout(0.5); s.connect(('127.0.0.1', int('$port'))); s.close()" 2>/dev/null; then
echo "pymta SMTP ready on port $port"
return 0
fi
sleep 0.5
done
echo "ERROR: pymta SMTP did not open port $port within 15s" >&2
kill "$PYMTA_PID" 2>/dev/null || true
return 1
}
cleanup() {
if [ -n "${PYMTA_PID:-}" ]; then
kill "$PYMTA_PID" 2>/dev/null || true
# Give pymta a chance to flush logs / drain sessions before we exit.
wait "$PYMTA_PID" 2>/dev/null || true
fi
}
trap cleanup INT TERM
if [ "${EXEC_CMD:-false}" = "true" ]; then
start_pymta
status=$?
if [ "$status" -ne 0 ]; then
echo "ERROR: pymta failed to start, not executing command" >&2
cleanup
exit "$status"
fi
"$@"
status=$?
cleanup
exit $status
fi
exec python -m pymta.server
+14 -2
View File
@@ -29,7 +29,9 @@ requires-python = ">=3.14.4,<4.0"
dependencies = [ dependencies = [
"requests==2.32.3", "requests==2.32.3",
"PyJWT==2.10.1", "PyJWT==2.10.1",
"pymilter==1.0.5" "aiosmtpd==1.4.6",
"httpx==0.28.1",
"prometheus-client==0.24.1",
] ]
[project.urls] [project.urls]
@@ -39,9 +41,15 @@ dependencies = [
"Repository" = "https://github.com/suitenumerique/st-messages" "Repository" = "https://github.com/suitenumerique/st-messages"
[project.optional-dependencies] [project.optional-dependencies]
# pymilter is a C extension that needs libmilter-dev at install time. Keep it
# behind an extra so the pure-Python pymta image can build without libmilter.
postfix = [
"pymilter==1.0.5",
]
dev = [ dev = [
"pytest==8.3.5", "pytest==8.3.5",
"pytest-cov==6.0.0", "pytest-cov==6.0.0",
"pytest-asyncio==0.24.0",
"fastapi==0.115.12", "fastapi==0.115.12",
"uvicorn==0.34.1", "uvicorn==0.34.1",
"ruff==0.9.3", "ruff==0.9.3",
@@ -92,7 +100,11 @@ sections = { django=["django"] }
extra-standard-library = ["tomllib"] extra-standard-library = ["tomllib"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"] "**/tests/*" = ["S", "SLF", "C405", "PLR0913"]
# The Postfix milter predates the pure-Python pymta module and intentionally
# uses bare except + print for stdout-based logging in the libmilter
# sync callback context.
"src/delivery_milter.py" = ["BLE001", "T201"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = [ addopts = [
+1 -1
View File
@@ -33,6 +33,7 @@ def mda_api_call(path, content_type, body, metadata):
now = datetime.datetime.now(datetime.timezone.utc) now = datetime.datetime.now(datetime.timezone.utc)
jwt_token = jwt.encode( jwt_token = jwt.encode(
{ {
**metadata,
"exp": now + datetime.timedelta(seconds=MDA_API_JWT_TTL), "exp": now + datetime.timedelta(seconds=MDA_API_JWT_TTL),
# The channel is authenticated by the HMAC signature over the shared # The channel is authenticated by the HMAC signature over the shared
# MDA_API_SECRET; body_hash binds the token to its payload (sha256 of # MDA_API_SECRET; body_hash binds the token to its payload (sha256 of
@@ -41,7 +42,6 @@ def mda_api_call(path, content_type, body, metadata):
# No jti/nonce: retries (and urllib3's) resend the same token, and # No jti/nonce: retries (and urllib3's) resend the same token, and
# the backend trusts the secret rather than tracking single use. # the backend trusts the secret rather than tracking single use.
"body_hash": hashlib.sha256(body).hexdigest(), "body_hash": hashlib.sha256(body).hexdigest(),
**metadata,
}, },
MDA_API_SECRET, MDA_API_SECRET,
algorithm="HS256", algorithm="HS256",
+10
View File
@@ -0,0 +1,10 @@
"""
Pure-Python inbound MTA built on aiosmtpd.
Reception-side counterpart to the Postfix+milter implementation that lives in
``src/mta-in/src/delivery_milter.py``. Both share the same MDA REST contract
(``inbound/mta/check/`` + ``inbound/mta/deliver/``), and both run as a
stateless, queue-less SMTP front-end: each SMTP session blocks on the
synchronous delivery HTTP call and translates the outcome straight back to the
remote peer.
"""
+186
View File
@@ -0,0 +1,186 @@
"""RFC 5321 envelope-address validation.
The functions in this module are intentionally strict: they reject anything
the inbound SMTP server should not have to deal with — source routes
(RFC 5321 §4.1.1.3), control characters (CRLF injection vector), overlong
local-parts or domains, and the common ``user@`` / ``@domain`` truncations.
They never accept already-unbalanced quoting or angle brackets.
:func:`validate_envelope_address` accepts either the wrapped (``<user@host>``)
or unwrapped form; :func:`strip_brackets` runs unconditionally on entry.
"""
from __future__ import annotations
# Characters that must never appear unquoted in an envelope address. CR, LF,
# and NUL are the CRLF-injection and frame-confusion vectors. TAB is a header
# unfolding vector. ``%`` is included to keep us out of historical
# "percent-routing" relay tricks (RFC 1123 §5.2.16). DEL (0x7f) and bare
# space have no place in an address received from the wire.
_FORBIDDEN_CHARS = frozenset({"\r", "\n", "\x00", "\t", " ", "%", "\x7f"})
class AddressError(ValueError):
"""Raised when an envelope address fails validation.
The ``reason`` attribute carries a short token suitable for a Prometheus
metric label and the ``smtp_code`` / ``smtp_text`` tuple gives the exact
SMTP reply the caller should send back to the peer.
"""
def __init__(self, reason: str, smtp_text: str, smtp_code: int = 553):
super().__init__(smtp_text)
self.reason = reason
self.smtp_text = smtp_text
self.smtp_code = smtp_code
def strip_brackets(raw: str) -> str:
"""Strip a single pair of surrounding angle brackets.
Returns the input unchanged if there is no leading ``<``. Does not validate
that the address inside is well-formed.
"""
if not raw:
return raw
if raw.startswith("<") and raw.endswith(">"):
return raw[1:-1]
return raw
def validate_envelope_address( # noqa: PLR0912
raw: str,
*,
allow_empty: bool,
max_local: int,
max_domain: int,
) -> str:
"""Validate ``raw`` as an RFC 5321 envelope address.
``allow_empty`` controls whether the null sender ``<>`` is accepted. It
must be true for MAIL FROM and false for RCPT TO (RFC 5321 §4.5.5).
Returns the cleaned address (lower-cased domain, original local-part) on
success, raises :class:`AddressError` on failure.
"""
address = strip_brackets(raw or "")
if address == "":
if allow_empty:
return ""
raise AddressError(
reason="bad_address",
smtp_code=553,
smtp_text="5.1.3 Empty recipient address not allowed",
)
# ----- 1a. residual angle brackets ---------------------------------------
# strip_brackets only removes a balanced outer pair; any leftover '<' or '>'
# means the address is malformed (unbalanced or nested brackets).
if "<" in address or ">" in address:
raise AddressError(
reason="bad_address",
smtp_code=501,
smtp_text="5.1.3 Malformed address syntax",
)
# ----- 1b. control / CRLF / NUL injection --------------------------------
bad = _FORBIDDEN_CHARS & set(address)
if bad:
raise AddressError(
reason="control_char",
smtp_code=501,
smtp_text="5.1.7 Address contains forbidden control characters",
)
# ----- 2. source routes @host1,@host2:user@host3 -----------------------
# RFC 5321 §4.1.1.3 allows ignoring source routes; we reject outright.
if address.startswith("@"):
raise AddressError(
reason="source_route",
smtp_code=553,
smtp_text="5.1.3 Source routes are not accepted",
)
# ----- 3. exactly one unquoted '@' ---------------------------------------
# Quoted local-parts could legally contain '@', but we don't accept those
# on the public inbound path — most senders never use them and they are
# a fertile parser-confusion ground.
if address.count("@") != 1:
raise AddressError(
reason="bad_address",
smtp_code=501,
smtp_text="5.1.3 Bad address syntax",
)
local, _, domain = address.partition("@")
if not local or not domain:
raise AddressError(
reason="bad_address",
smtp_code=501,
smtp_text="5.1.3 Bad address syntax",
)
# ----- 3a. quoted local-parts -------------------------------------------
# aiosmtpd's email-parser unwraps the quoted form (``"a"@b.com`` arrives
# with quotes intact in the local-part). The MDA's mailbox lookup
# normalises differently than our address validator, which is a
# parser-mismatch vector. Reject double quotes outright.
if '"' in local:
raise AddressError(
reason="bad_address",
smtp_code=553,
smtp_text="5.1.3 Quoted local-parts not accepted",
)
# ----- 3b. dot placement in unquoted local-part (RFC 5321 §4.1.2) -------
# A leading dot, trailing dot, or two consecutive dots are illegal in an
# unquoted local-part. Different mailbox-lookup paths normalise these
# inconsistently, so reject at the gate.
if local.startswith(".") or local.endswith(".") or ".." in local:
raise AddressError(
reason="bad_address",
smtp_code=553,
smtp_text="5.1.3 Malformed local part",
)
# ----- 4. length limits (RFC 5321 §4.5.3.1) -----------------------------
if len(local.encode("utf-8")) > max_local:
raise AddressError(
reason="oversize_local",
smtp_code=553,
smtp_text=f"5.1.3 Local part exceeds {max_local} octets",
)
if len(domain.encode("utf-8")) > max_domain:
raise AddressError(
reason="oversize_domain",
smtp_code=553,
smtp_text=f"5.1.3 Domain part exceeds {max_domain} octets",
)
# ----- 5. domain shape ---------------------------------------------------
# Allow IDN/UTF-8 in the domain; reject empty labels, leading dot, label
# > 63 octets, and bare IP literals (the inbound path expects FQDNs from
# legitimate senders).
if domain.startswith("[") and domain.endswith("]"):
raise AddressError(
reason="address_literal",
smtp_code=501,
smtp_text="5.1.3 Address literals not accepted",
)
if domain.startswith(".") or domain.endswith(".") or ".." in domain:
raise AddressError(
reason="bad_address",
smtp_code=501,
smtp_text="5.1.3 Malformed domain",
)
for label in domain.split("."):
if not label or len(label.encode("utf-8")) > 63:
raise AddressError(
reason="bad_address",
smtp_code=501,
smtp_text="5.1.3 Malformed domain label",
)
return f"{local}@{domain.lower()}"
+97
View File
@@ -0,0 +1,97 @@
"""aiosmtpd Controller wired to our :class:`HardenedSMTP` factory.
The Controller itself is unchanged structurally — all hardening lives inside
:class:`HardenedSMTP` so the admission gate runs in the same coroutine that
will dispatch SMTP verbs.
"""
from __future__ import annotations
import asyncio
import logging
import ssl
from aiosmtpd.controller import UnthreadedController
from . import settings
from .handler import InboundHandler
from .limits import IPGate
from .smtp_protocol import HardenedSMTP
logger = logging.getLogger(__name__)
def build_smtp_kwargs(*, tls_context: ssl.SSLContext | None) -> dict:
"""Centralise the SMTP-class options driven by settings."""
return {
"hostname": settings.PYMTA_HOSTNAME,
"ident": settings.PYMTA_IDENT,
"data_size_limit": settings.MAX_INCOMING_EMAIL_SIZE,
"enable_SMTPUTF8": settings.PYMTA_ENABLE_SMTPUTF8,
"timeout": settings.PYMTA_COMMAND_TIMEOUT,
# Per-verb call ceilings (defence against pipelining floods). Numbers
# come from "what a sane sender would ever do in one TCP session";
# anything above means the peer is hammering us.
"command_call_limit": {
"EHLO": 4,
"HELO": 4,
"NOOP": 5,
"MAIL": settings.PYMTA_MAX_ENVELOPES_PER_CONNECTION + 2,
"RCPT": settings.PYMTA_MAX_RECIPIENTS * settings.PYMTA_MAX_ENVELOPES_PER_CONNECTION
+ 10,
"DATA": settings.PYMTA_MAX_ENVELOPES_PER_CONNECTION + 2,
"RSET": 20,
"QUIT": 1,
# STARTTLS pinned explicitly so a future contributor cannot raise
# the "*" bucket and silently let a peer burn TLS handshakes by
# repeating EHLO/STARTTLS within one TCP session.
"STARTTLS": 2,
"*": 25,
},
"proxy_protocol_timeout": (
settings.PYMTA_PROXY_PROTOCOL_TIMEOUT if settings.PYMTA_ENABLE_PROXY_PROTOCOL else None
),
"tls_context": tls_context,
# Plaintext AUTH on port 25 is unsafe; AUTH stays off entirely.
"auth_require_tls": True,
"auth_required": False,
"auth_exclude_mechanism": ("LOGIN", "PLAIN"), # ggignore
}
def load_tls_context() -> ssl.SSLContext | None:
"""Build a TLS context from the configured cert/key, or None.
Returning None disables STARTTLS — aiosmtpd will not advertise it.
"""
cert = settings.PYMTA_TLS_CERT_FILE
key = settings.PYMTA_TLS_KEY_FILE
if not cert or not key:
return None
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
# As an MTA we accept any client identity; we just want our side encrypted.
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.load_cert_chain(cert, key)
return ctx
class HardenedController(UnthreadedController):
""":class:`UnthreadedController` returning a :class:`HardenedSMTP`."""
def __init__(
self,
handler: InboundHandler,
*,
ip_gate: IPGate,
hostname: str,
port: int,
loop: asyncio.AbstractEventLoop | None = None,
):
self._ip_gate = ip_gate
tls_context = load_tls_context()
self._smtp_kwargs = build_smtp_kwargs(tls_context=tls_context)
super().__init__(handler, hostname=hostname, port=port, loop=loop)
def factory(self) -> HardenedSMTP: # type: ignore[override]
return HardenedSMTP(self.handler, ip_gate=self._ip_gate, **self._smtp_kwargs)
+336
View File
@@ -0,0 +1,336 @@
"""aiosmtpd handler implementing the queue-less inbound delivery flow.
For each SMTP transaction the handler
1. validates EHLO syntax,
2. validates and stores MAIL FROM (allowing the null sender),
3. on RCPT TO: validates the address shape, then calls the MDA
``inbound/mta/check/`` endpoint synchronously — RCPT is rejected with a
permanent 5xx if the mailbox does not exist, a 4xx if the check itself
fails or times out,
4. on DATA: forwards the full message bytes to ``inbound/mta/deliver/``
and translates the MDA outcome back to a single SMTP reply line.
The handler keeps no on-disk queue and no persistent envelope log: a 250 to
the peer means the MDA has already accepted the message; a 4xx means the
peer should retry later.
"""
from __future__ import annotations
import asyncio
import logging
from . import metrics, settings
from .address import AddressError, validate_envelope_address
from .mda_async import MDAClient, MDAResult
logger = logging.getLogger(__name__)
# Per-session counters live on the Session object (one per TCP connection).
# aiosmtpd resets ``envelope`` after each DATA so we cannot stash counters
# there; we attach to ``session`` via setattr instead.
_ENVELOPES_ATTR = "_pymta_envelopes"
_SOFT_ERRORS_ATTR = "_pymta_soft_errors"
_RCPT_MISSES_ATTR = "_pymta_rcpt_misses"
# Sentinel for the RFC 5321 null sender (MAIL FROM:<>). aiosmtpd's
# ``smtp_RCPT`` rejects with 503 when ``envelope.mail_from`` is falsy, which
# would block legitimate bounces. We keep the sentinel internally and rewrite
# it back to the empty string when calling the MDA, matching the Postfix
# milter's existing wire contract.
NULL_SENDER_SENTINEL = "<>"
# Control characters that must never appear in an EHLO/HELO hostname or
# anywhere else we'll log / pass into HTTP claims. CR, LF, NUL are the
# CRLF-injection vectors; TAB is a header-unfolding vector.
_FORBIDDEN_HOSTNAME_CHARS = frozenset({"\r", "\n", "\x00", "\t"})
def _envelopes_count(session) -> int:
return getattr(session, _ENVELOPES_ATTR, 0)
def _bump_envelopes(session) -> int:
n = _envelopes_count(session) + 1
setattr(session, _ENVELOPES_ATTR, n)
return n
def _bump_soft_errors(session) -> int:
n = getattr(session, _SOFT_ERRORS_ATTR, 0) + 1
setattr(session, _SOFT_ERRORS_ATTR, n)
return n
def _bump_rcpt_misses(session) -> int:
n = getattr(session, _RCPT_MISSES_ATTR, 0) + 1
setattr(session, _RCPT_MISSES_ATTR, n)
return n
def _peer_ip(session) -> str | None:
proxy_data = getattr(session, "proxy_data", None)
if proxy_data is not None and getattr(proxy_data, "src_addr", None):
return str(proxy_data.src_addr)
peer = getattr(session, "peer", None)
if peer and len(peer) >= 1:
return str(peer[0])
return None
def _peer_port(session) -> str | None:
proxy_data = getattr(session, "proxy_data", None)
if proxy_data is not None and getattr(proxy_data, "src_port", None) is not None:
return str(proxy_data.src_port)
peer = getattr(session, "peer", None)
if peer and len(peer) >= 2:
return str(peer[1])
return None
def _safe_hostname(raw: str | None, session=None) -> str | None:
"""Return ``raw`` only if it is free of CRLF/NUL/TAB; otherwise None.
The MDA receives this through a JWT claim; downstream consumers may
interpolate it into log lines or ``Received`` headers, so a control char
here is a header-injection vector.
When ``session`` is supplied, a rejected hostname is counted and logged so
operators can spot floods of malformed HELO/EHLO greetings.
"""
if raw is None:
return None
if _FORBIDDEN_HOSTNAME_CHARS & set(raw):
metrics.SECURITY_REJECTIONS.labels(reason="bad_helo").inc()
logger.info("dropping HELO/EHLO with forbidden control chars from %s", _peer_ip(session))
return None
return raw
class InboundHandler:
"""One instance per server process; called concurrently from many sessions."""
def __init__(self, mda_client: MDAClient):
self.mda = mda_client
# ------------------------------------------------------------------ EHLO
async def handle_EHLO(self, server, session, envelope, hostname, responses):
"""Customize the EHLO response list.
Strips any extension keyword we've decided not to expose on inbound
port 25: AUTH (would invite credential-stuffing or open relay if
misconfigured), CHUNKING/BDAT (smuggling parser-confusion surface),
and PIPELINING (announcing it advertises that we accept rapid command
coalescing; the actual per-verb rate cap lives in
``controller.py:command_call_limit``). aiosmtpd already omits these
by default; we keep the filter as a guard against future regressions
or a contributor wiring an authenticator without re-reading the
security rationale.
"""
denied_verbs = {"AUTH", "CHUNKING", "BDAT", "PIPELINING"}
clean: list[str] = []
for line in responses:
# line looks like "250-FOO bar" or "250 FOO bar".
after = line[4:] if len(line) > 4 else ""
verb = after.split(" ", 1)[0].upper()
if verb in denied_verbs:
metrics.SECURITY_REJECTIONS.labels(reason="auth_offered").inc()
logger.warning(
"stripping disallowed EHLO extension %r from %s — review the "
"SMTP configuration so it is not advertised in the first place",
verb,
_peer_ip(session),
)
continue
clean.append(line)
session.host_name = _safe_hostname(hostname, session=session)
return clean
async def handle_HELO(self, server, session, envelope, hostname):
session.host_name = _safe_hostname(hostname, session=session)
return f"250 {server.hostname}"
# ------------------------------------------------------------------ MAIL
async def handle_MAIL(self, server, session, envelope, address, mail_options):
try:
clean = validate_envelope_address(
address,
allow_empty=True,
max_local=settings.PYMTA_MAX_LOCAL_PART,
max_domain=settings.PYMTA_MAX_DOMAIN,
)
except AddressError as err:
metrics.SECURITY_REJECTIONS.labels(reason=err.reason).inc()
return f"{err.smtp_code} {err.smtp_text}"
# Honour MAIL FROM:... SIZE=N if announced — fail fast before DATA.
for opt in mail_options or []:
if opt.upper().startswith("SIZE="):
try:
announced = int(opt.split("=", 1)[1])
except ValueError:
_bump_soft_errors(session)
return "501 5.5.4 Bad SIZE parameter"
if announced > settings.MAX_INCOMING_EMAIL_SIZE:
metrics.SECURITY_REJECTIONS.labels(reason="oversize_announced").inc()
_bump_soft_errors(session)
return "552 5.3.4 Message size exceeds fixed maximum"
envelope.mail_from = clean if clean else NULL_SENDER_SENTINEL
envelope.mail_options.extend(mail_options or [])
return "250 2.1.0 OK"
# ------------------------------------------------------------------ RCPT
async def handle_RCPT(self, server, session, envelope, address, rcpt_options): # noqa: PLR0911
# First gate: hard-error budget. Once the session has accumulated
# ``PYMTA_HARD_ERROR_LIMIT`` 4xx/5xx replies, send 421 and close so
# bulk address enumeration / dictionary attacks cannot keep hammering
# this single TCP session.
if getattr(session, _SOFT_ERRORS_ATTR, 0) >= settings.PYMTA_HARD_ERROR_LIMIT:
metrics.SECURITY_REJECTIONS.labels(reason="hard_error_limit").inc()
metrics.DISCONNECTS_421.labels(reason="hard_error_limit").inc()
metrics.RCPT_TOTAL.labels(result="rejected_temp").inc()
return "421 4.7.0 Too many errors, goodbye"
# Per-envelope recipient cap.
if len(envelope.rcpt_tos) >= settings.PYMTA_MAX_RECIPIENTS:
metrics.SECURITY_REJECTIONS.labels(reason="max_recipients").inc()
metrics.RCPT_TOTAL.labels(result="rejected_temp").inc()
_bump_soft_errors(session)
return "452 4.5.3 Too many recipients"
try:
clean = validate_envelope_address(
address,
allow_empty=False,
max_local=settings.PYMTA_MAX_LOCAL_PART,
max_domain=settings.PYMTA_MAX_DOMAIN,
)
except AddressError as err:
metrics.SECURITY_REJECTIONS.labels(reason=err.reason).inc()
metrics.RCPT_TOTAL.labels(result="rejected_perm").inc()
_bump_soft_errors(session)
return f"{err.smtp_code} {err.smtp_text}"
result = await self.mda.check_recipient(clean)
if result.temp_fail:
metrics.RCPT_TOTAL.labels(result="rejected_temp").inc()
_bump_soft_errors(session)
return "451 4.3.0 Recipient verification temporarily unavailable"
if not result.ok:
metrics.RCPT_TOTAL.labels(result="rejected_perm").inc()
_bump_soft_errors(session)
return "550 5.1.1 Recipient verification failed"
exists = bool(result.payload.get(clean, False))
if not exists:
metrics.RCPT_TOTAL.labels(result="rejected_perm").inc()
_bump_soft_errors(session)
misses = _bump_rcpt_misses(session)
if misses >= settings.PYMTA_MAX_RCPT_MISSES_PER_SESSION:
metrics.SECURITY_REJECTIONS.labels(reason="max_rcpt_misses").inc()
metrics.DISCONNECTS_421.labels(reason="max_rcpt_misses").inc()
return "421 4.7.0 Too many unknown recipients, goodbye"
return "550 5.1.1 No such recipient"
envelope.rcpt_tos.append(clean)
envelope.rcpt_options.extend(rcpt_options or [])
metrics.RCPT_TOTAL.labels(result="accepted").inc()
return "250 2.1.5 OK"
# ------------------------------------------------------------------ DATA
async def handle_DATA(self, server, session, envelope): # noqa: PLR0911
envelopes = _bump_envelopes(session)
if envelopes > settings.PYMTA_MAX_ENVELOPES_PER_CONNECTION:
metrics.SECURITY_REJECTIONS.labels(reason="max_envelopes").inc()
metrics.MESSAGES_TOTAL.labels(result="rejected_temp").inc()
_bump_soft_errors(session)
return "451 4.7.0 Too many messages this session"
content: bytes = envelope.content or b""
# NUL bytes have no place in an RFC 5321 message and break downstream
# C parsers — reject before we pay the cost of the deliver call.
if b"\x00" in content:
metrics.SECURITY_REJECTIONS.labels(reason="nul_byte").inc()
metrics.MESSAGES_TOTAL.labels(result="rejected_perm").inc()
_bump_soft_errors(session)
return "554 5.6.0 NUL byte in message body"
if len(content) > settings.MAX_INCOMING_EMAIL_SIZE:
# aiosmtpd already replies 552 itself when the in-flight DATA
# exceeds data_size_limit, so reaching here is defensive only.
metrics.SECURITY_REJECTIONS.labels(reason="oversize_announced").inc()
metrics.MESSAGES_TOTAL.labels(result="rejected_perm").inc()
_bump_soft_errors(session)
return "552 5.3.4 Message size exceeds fixed maximum"
try:
sender = envelope.mail_from
if sender == NULL_SENDER_SENTINEL:
sender = ""
result: MDAResult = await asyncio.wait_for(
self.mda.deliver(
message=content,
sender=sender,
original_recipients=list(envelope.rcpt_tos),
client_address=_peer_ip(session),
client_port=_peer_port(session),
# We do not reverse-DNS ourselves: the MDA inserts its
# own Received header using metadata and can decide what
# to do with the missing hostname.
client_hostname=None,
client_helo=_safe_hostname(getattr(session, "host_name", None), session=session),
),
timeout=settings.PYMTA_DATA_TIMEOUT,
)
except TimeoutError:
metrics.MESSAGES_TOTAL.labels(result="rejected_temp").inc()
metrics.MESSAGE_BYTES.observe(len(content))
_bump_soft_errors(session)
logger.warning(
"DATA deliver deadline exceeded (%ds) for peer %s",
settings.PYMTA_DATA_TIMEOUT,
_peer_ip(session),
)
return "451 4.3.0 Delivery timed out, please retry"
metrics.MESSAGE_BYTES.observe(len(content))
if result.ok and result.payload.get("status") == "ok":
metrics.MESSAGES_TOTAL.labels(result="delivered").inc()
return "250 2.0.0 Message accepted for delivery"
if result.temp_fail:
metrics.MESSAGES_TOTAL.labels(result="rejected_temp").inc()
_bump_soft_errors(session)
return "451 4.3.0 Delivery temporarily unavailable"
metrics.MESSAGES_TOTAL.labels(result="rejected_perm").inc()
_bump_soft_errors(session)
return "554 5.6.0 Message rejected by delivery agent"
# ------------------------------------------------------------------ PROXY
async def handle_PROXY(self, server, session, envelope, proxy_data):
"""Apply admission control once PROXY-protocol parsing is done.
Routing the gate through here (rather than at SMTP-connect time)
means we count sessions against the real client IP carried in the
PROXY header, not against the load-balancer's IP. Without this,
every session behind HAProxy would be bucketed under one address
and ``PYMTA_MAX_SESSIONS_PER_IP`` would silently turn into a global
cap.
"""
real_ip = "unknown"
if proxy_data is not None and getattr(proxy_data, "src_addr", None):
real_ip = str(proxy_data.src_addr)
return await server.acquire_gate_post_proxy(real_ip)
# ------------------------------------------------------------------ misc
async def handle_exception(self, error: BaseException) -> str:
# Never leak stack traces or internal hostnames in SMTP replies.
metrics.SECURITY_REJECTIONS.labels(reason="internal_error").inc()
metrics.DISCONNECTS_421.labels(reason="internal_error").inc()
logger.exception("Unhandled error in SMTP handler")
return "421 4.3.0 Internal error, please try again later"
+118
View File
@@ -0,0 +1,118 @@
"""Connection-level admission control for the pymta server.
The :class:`IPGate` enforces three ceilings on inbound TCP sessions:
* a process-wide cap, defending against a generic flood;
* a per-IP concurrent cap, defending against a single remote opening thousands
of half-idle connections (aiosmtpd does not enforce any per-IP cap);
* a per-IP new-session rate cap (rolling 60s window), defending against fast
open/close churn from one IP that never exceeds the concurrent cap but
still costs CPU/TLS handshakes/MDA RCPT checks.
All caps are skipped when set to 0, matching the existing Postfix default
(``smtpd_client_event_limit_exceptions = static:all``) — useful in dev/test
where the whole load comes from the same loopback address.
"""
from __future__ import annotations
import asyncio
import logging
import time
from . import metrics
logger = logging.getLogger(__name__)
# Rolling window used by the per-IP rate cap.
_RATE_WINDOW_SECONDS = 60.0
# Opportunistic prune cadence for the rate-tracking dict: walk and drop
# expired entries every Nth acquire. Bounds memory under PROXY-protocol with
# many distinct client IPs (each entry would otherwise live one full window
# beyond its last use).
_RATE_PRUNE_EVERY = 1000
class TooManyConnections(Exception):
"""Raised when the global, per-IP concurrent, or per-IP rate cap is hit."""
def __init__(self, scope: str):
super().__init__(scope)
self.scope = scope
class IPGate:
"""Tracks live SMTP sessions per remote IP and globally.
Acquisition does not block: if any cap is reached we raise immediately
so the caller can close the socket and reply ``421`` instead of holding
the connection open and amplifying the attack.
The ``_try_acquire`` / ``_release`` pair is called from
:class:`pymta.smtp_protocol.HardenedSMTP` (post-PROXY when applicable).
"""
def __init__(
self,
*,
max_total: int,
max_per_ip: int,
max_per_ip_per_minute: int = 0,
clock=time.monotonic,
):
self.max_total = max_total
self.max_per_ip = max_per_ip
self.max_per_ip_per_minute = max_per_ip_per_minute
self._clock = clock
self._lock = asyncio.Lock()
self._per_ip: dict[str, int] = {}
self._total = 0
# (count_in_window, window_start_monotonic) per IP
self._rate_per_ip: dict[str, tuple[int, float]] = {}
self._acquires_since_prune = 0
async def _try_acquire(self, ip: str) -> None:
async with self._lock:
if self.max_total and self._total >= self.max_total:
raise TooManyConnections("global")
if self.max_per_ip and self._per_ip.get(ip, 0) >= self.max_per_ip:
raise TooManyConnections("per_ip")
if self.max_per_ip_per_minute:
now = self._clock()
count, start = self._rate_per_ip.get(ip, (0, now))
if now - start >= _RATE_WINDOW_SECONDS:
count, start = 0, now
if count >= self.max_per_ip_per_minute:
raise TooManyConnections("per_ip_rate")
self._rate_per_ip[ip] = (count + 1, start)
self._acquires_since_prune += 1
if self._acquires_since_prune >= _RATE_PRUNE_EVERY:
self._prune_expired_rates(now)
self._acquires_since_prune = 0
self._per_ip[ip] = self._per_ip.get(ip, 0) + 1
self._total += 1
metrics.SESSIONS_ACTIVE.inc()
metrics.SESSIONS_PER_IP.set(len(self._per_ip))
async def _release(self, ip: str) -> None:
async with self._lock:
new = self._per_ip.get(ip, 0) - 1
if new <= 0:
self._per_ip.pop(ip, None)
else:
self._per_ip[ip] = new
self._total = max(0, self._total - 1)
metrics.SESSIONS_ACTIVE.dec()
metrics.SESSIONS_PER_IP.set(len(self._per_ip))
def _prune_expired_rates(self, now: float) -> None:
# Drop IPs whose window has fully elapsed; keeps the rate dict bounded
# to the set of IPs seen in roughly the last minute.
expired = [
ip
for ip, (_count, start) in self._rate_per_ip.items()
if now - start >= _RATE_WINDOW_SECONDS
]
for ip in expired:
del self._rate_per_ip[ip]
+289
View File
@@ -0,0 +1,289 @@
"""Async HTTP client for the MDA inbound API.
The Postfix milter uses ``requests`` (sync, see ``src/api/mda.py``). pymta
runs inside an asyncio event loop, so blocking HTTP calls would freeze the
whole SMTP server; we mirror the same JWT contract here on top of httpx.
The MDA contract — kept identical to the milter so both implementations stay
swap-compatible — is:
* ``POST /inbound/mta/check/`` with ``application/json`` body
``{"addresses": [...]}`` → returns ``{addr: bool}``.
* ``POST /inbound/mta/deliver/`` with ``message/rfc822`` body (the full
message bytes). The metadata (sender, recipients, client info, size) is
carried as JWT claims, not in the body.
Every request is signed with a short-lived HS256 JWT whose body_hash claim
binds the JWT to the exact bytes being posted (replay-proofing).
"""
from __future__ import annotations
import datetime
import hashlib
import json
import logging
import time
from dataclasses import dataclass
from urllib.parse import urlparse
import httpx
import jwt
from . import metrics, settings
logger = logging.getLogger(__name__)
# Local development URLs are the only place we tolerate a plaintext MDA;
# anywhere else a leaked JWT secret on the wire is a credential incident.
_LOCAL_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"})
# Below this many bytes a shared HS256 secret is brute-forceable; refuse to
# even start the process rather than minting weak tokens.
_MIN_SECRET_LENGTH = 32
@dataclass(frozen=True)
class MDAResult:
"""Result of an MDA call.
``ok`` is true iff the call returned HTTP 200 with a JSON body that the
caller can rely on. ``temp_fail`` distinguishes "try again later" (network
error / 5xx / timeout) from a permanent rejection. ``payload`` is the
decoded JSON body when available.
"""
ok: bool
temp_fail: bool
payload: dict
status_code: int
class MDAClient:
"""Thin async wrapper over the MDA REST API.
Lifetime: one instance per server process. Reuses one
:class:`httpx.AsyncClient` so the HTTP channel survives many SMTP
sessions (HTTP keep-alive); each individual SMTP transaction still
blocks on a synchronous MDA call so there is no on-disk queue.
"""
def __init__(
self,
base_url: str | None = None,
secret: str | None = None,
timeout: int | None = None,
breaker_threshold: int | None = None,
breaker_cooldown: int | None = None,
clock=time.monotonic,
):
self.base_url = (base_url or settings.MDA_API_BASE_URL).rstrip("/") + "/"
self.secret = secret or settings.MDA_API_SECRET
self.timeout = timeout if timeout is not None else settings.MDA_API_TIMEOUT
self._breaker_threshold = (
breaker_threshold
if breaker_threshold is not None
else settings.PYMTA_MDA_BREAKER_THRESHOLD
)
self._breaker_cooldown = (
breaker_cooldown
if breaker_cooldown is not None
else settings.PYMTA_MDA_BREAKER_COOLDOWN
)
self._clock = clock
# Counts consecutive failures. Reset to 0 by any successful call.
self._consecutive_failures = 0
# Monotonic-time deadline until which the breaker stays open. None
# when closed; a future timestamp when open.
self._open_until: float | None = None
self._client: httpx.AsyncClient | None = None
self._validate_credentials()
def _validate_credentials(self) -> None:
"""Warn loudly at startup about weak secret or plaintext non-local MDA URL.
Warnings rather than hard failures because the shared dev secret
``my-shared-secret-mda`` (20 chars) is intentionally short, and dev
deployments talk to the MDA over the docker bridge without TLS. The
log line gives a prod operator clear feedback to fix; promote to
``RuntimeError`` here once prod has migrated to a stronger secret.
"""
parsed = urlparse(self.base_url)
host = (parsed.hostname or "").lower()
if parsed.scheme == "http" and host not in _LOCAL_HOSTNAMES:
logger.warning(
"MDA_API_BASE_URL uses plaintext http:// for non-local host %r "
"(%r). The JWT bearer token will traverse the network in clear. "
"Configure https:// in production.",
host,
self.base_url,
)
if self.secret and len(self.secret) < _MIN_SECRET_LENGTH:
logger.warning(
"MDA_API_SECRET is %d bytes; recommended minimum is %d. "
"Short HS256 secrets are brute-forceable from a captured JWT.",
len(self.secret),
_MIN_SECRET_LENGTH,
)
async def start(self) -> httpx.AsyncClient:
"""Open the persistent HTTP client. Idempotent."""
if self._client is None:
limits = httpx.Limits(max_keepalive_connections=20, max_connections=100)
self._client = httpx.AsyncClient(timeout=self.timeout, limits=limits)
return self._client
async def close(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
def _build_jwt(self, body: bytes, metadata: dict) -> str:
if not self.secret:
raise RuntimeError("MDA_API_SECRET is required to sign MDA API requests")
# Spread metadata FIRST so a stray metadata key named "exp" or
# "body_hash" cannot shadow the security-relevant claims.
claims = {
**metadata,
"exp": datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(seconds=60),
"body_hash": hashlib.sha256(body).hexdigest(),
}
return jwt.encode(claims, self.secret, algorithm="HS256")
def _breaker_open(self) -> bool:
"""True when the circuit is currently shedding traffic."""
if self._open_until is None:
return False
if self._clock() >= self._open_until:
# Cool-down elapsed; let the next request probe upstream.
self._open_until = None
self._consecutive_failures = 0
return False
return True
def _record_failure(self) -> None:
if not self._breaker_threshold:
return
self._consecutive_failures += 1
if self._consecutive_failures >= self._breaker_threshold and self._open_until is None:
self._open_until = self._clock() + self._breaker_cooldown
logger.warning(
"MDA circuit breaker OPEN after %d consecutive failures; "
"fast-failing for %ds",
self._consecutive_failures,
self._breaker_cooldown,
)
def _record_success(self) -> None:
if self._consecutive_failures and self._open_until is None:
logger.info(
"MDA recovered after %d consecutive failures", self._consecutive_failures
)
self._consecutive_failures = 0
async def _post(
self,
path: str,
content_type: str,
body: bytes,
metadata: dict,
endpoint_label: str,
) -> MDAResult:
if self._breaker_open():
metrics.MDA_REQUEST_DURATION.labels(
endpoint=endpoint_label, result="breaker_open"
).observe(0)
return MDAResult(ok=False, temp_fail=True, payload={}, status_code=0)
client = self._client or await self.start()
url = self.base_url + path.lstrip("/")
token = self._build_jwt(body, metadata)
headers = {"Content-Type": content_type, "Authorization": f"Bearer {token}"}
start = self._clock()
try:
response = await client.post(url, content=body, headers=headers)
except httpx.TimeoutException:
metrics.MDA_REQUEST_DURATION.labels(endpoint=endpoint_label, result="timeout").observe(
self._clock() - start
)
logger.warning("MDA %s timeout after %.2fs", endpoint_label, self._clock() - start)
self._record_failure()
return MDAResult(ok=False, temp_fail=True, payload={}, status_code=0)
except httpx.HTTPError:
metrics.MDA_REQUEST_DURATION.labels(endpoint=endpoint_label, result="error").observe(
self._clock() - start
)
logger.exception("MDA %s transport error", endpoint_label)
self._record_failure()
return MDAResult(ok=False, temp_fail=True, payload={}, status_code=0)
elapsed = self._clock() - start
# JSON decode is best-effort; some error bodies may be HTML.
try:
payload = response.json() if response.content else {}
except json.JSONDecodeError:
payload = {}
status = response.status_code
if status == 200:
metrics.MDA_REQUEST_DURATION.labels(endpoint=endpoint_label, result="ok").observe(
elapsed
)
self._record_success()
return MDAResult(ok=True, temp_fail=False, payload=payload, status_code=status)
# 5xx → tempfail (counted as a breaker failure); 4xx → permanent reject
# (not counted — it's the MDA telling us the *request* was bad).
temp = status >= 500
result_label = "http_5xx" if temp else "http_4xx"
metrics.MDA_REQUEST_DURATION.labels(endpoint=endpoint_label, result=result_label).observe(
elapsed
)
logger.warning("MDA %s returned HTTP %d", endpoint_label, status)
if temp:
self._record_failure()
else:
self._record_success()
return MDAResult(ok=False, temp_fail=temp, payload=payload, status_code=status)
async def check_recipient(self, address: str) -> MDAResult:
"""Ask the MDA whether a single recipient mailbox exists."""
body = json.dumps({"addresses": [address]}, separators=(",", ":")).encode("utf-8")
return await self._post(
"inbound/mta/check/",
"application/json",
body,
metadata={},
endpoint_label="check",
)
async def deliver( # noqa: PLR0913
self,
*,
message: bytes,
sender: str,
original_recipients: list[str],
client_address: str | None,
client_port: str | None,
client_hostname: str | None,
client_helo: str | None,
) -> MDAResult:
"""Push the complete message to the MDA for synchronous delivery."""
metadata = {
"sender": sender,
"original_recipients": list(original_recipients),
"client_address": client_address,
"client_port": client_port,
"client_hostname": client_hostname,
"client_helo": client_helo,
"size": str(len(message)),
}
return await self._post(
"inbound/mta/deliver/",
"message/rfc822",
message,
metadata=metadata,
endpoint_label="deliver",
)
+104
View File
@@ -0,0 +1,104 @@
"""Prometheus metrics for the pymta server.
The metrics HTTP endpoint is started from :mod:`pymta.server`. Each metric
intentionally has a low cardinality (no email addresses, no client IPs in
labels) to keep the time-series space bounded.
"""
import logging
from prometheus_client import Counter, Gauge, Histogram, start_http_server
logger = logging.getLogger(__name__)
_METRICS_NAMESPACE = "pymta"
CONNECTIONS_TOTAL = Counter(
f"{_METRICS_NAMESPACE}_connections_total",
"Total inbound TCP connections, by post-accept outcome.",
# accepted | rejected_per_ip | rejected_per_ip_rate | rejected_global | proxy_error
labelnames=("result",),
)
SESSIONS_ACTIVE = Gauge(
f"{_METRICS_NAMESPACE}_sessions_active",
"Currently active SMTP sessions (post-PROXY, pre-close).",
)
SESSIONS_PER_IP = Gauge(
f"{_METRICS_NAMESPACE}_sessions_per_ip",
"Distinct remote IPs currently holding at least one session.",
)
SESSION_DURATION = Histogram(
f"{_METRICS_NAMESPACE}_session_duration_seconds",
"Wall-clock time from accept to close of an SMTP session.",
buckets=(0.05, 0.1, 0.5, 1, 5, 10, 30, 60, 120, 300, 600),
)
COMMANDS_TOTAL = Counter(
f"{_METRICS_NAMESPACE}_commands_total",
"SMTP commands processed, by verb and outcome class (2xx/4xx/5xx).",
labelnames=("verb", "class"),
)
RCPT_TOTAL = Counter(
f"{_METRICS_NAMESPACE}_rcpt_total",
"RCPT TO outcomes.",
labelnames=("result",), # accepted | rejected_perm | rejected_temp
)
MESSAGES_TOTAL = Counter(
f"{_METRICS_NAMESPACE}_messages_total",
"End-of-DATA delivery outcomes.",
labelnames=("result",), # delivered | rejected_perm | rejected_temp
)
MESSAGE_BYTES = Histogram(
f"{_METRICS_NAMESPACE}_message_bytes",
"Size of received messages in bytes.",
buckets=(1024, 10_000, 100_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 50_000_000),
)
MDA_REQUEST_DURATION = Histogram(
f"{_METRICS_NAMESPACE}_mda_request_duration_seconds",
"Latency of MDA API calls.",
labelnames=(
"endpoint",
"result",
), # endpoint: check|deliver, result: ok|http_5xx|timeout|error
buckets=(0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10, 30),
)
DISCONNECTS_421 = Counter(
f"{_METRICS_NAMESPACE}_disconnects_421_total",
"Sessions where pymta replied 421 and closed the TCP connection.",
labelnames=("reason",), # gate_global | gate_per_ip | gate_per_ip_rate |
# hard_error_limit | internal_error
)
SECURITY_REJECTIONS = Counter(
f"{_METRICS_NAMESPACE}_security_rejections_total",
"Requests rejected by an explicit hardening check, by reason.",
labelnames=("reason",),
# Known reasons: source_route, control_char, oversize_local, oversize_domain,
# nul_byte, oversize_announced, max_recipients, max_envelopes, auth_offered,
# bad_address, address_literal, bad_helo, hard_error_limit, max_rcpt_misses,
# internal_error
)
def start_metrics_server(host: str, port: int) -> None:
"""Start the Prometheus exposition HTTP server in a daemon thread.
``prometheus_client.start_http_server`` already spawns a background
thread, so this just adds a log line. Pass ``port=0`` to skip.
"""
if port <= 0:
logger.info("Prometheus metrics endpoint disabled (PYMTA_METRICS_PORT=0)")
return
start_http_server(port, addr=host)
logger.info("Prometheus metrics endpoint listening on %s:%d/metrics", host, port)
+107
View File
@@ -0,0 +1,107 @@
"""pymta entrypoint.
Run with ``python -m pymta.server``. Starts:
* the Prometheus exposition HTTP server (in a daemon thread),
* the SMTP listener (asyncio),
and exits on SIGINT/SIGTERM with an orderly shutdown that closes the listener
and waits for in-flight sessions to finish.
"""
from __future__ import annotations
import asyncio
import logging
import signal
import sys
from . import metrics, settings
from .controller import HardenedController
from .handler import InboundHandler
from .limits import IPGate
from .mda_async import MDAClient
logger = logging.getLogger(__name__)
def _configure_logging() -> None:
logging.basicConfig(
level=getattr(logging, settings.PYMTA_LOG_LEVEL, logging.INFO),
format="%(asctime)s %(name)s %(levelname)s %(message)s",
stream=sys.stdout,
)
async def _serve() -> None:
mda_client = MDAClient()
try:
await mda_client.start()
handler = InboundHandler(mda_client)
ip_gate = IPGate(
max_total=settings.PYMTA_MAX_SESSIONS_TOTAL,
max_per_ip=settings.PYMTA_MAX_SESSIONS_PER_IP,
max_per_ip_per_minute=settings.PYMTA_MAX_SESSIONS_PER_IP_PER_MINUTE,
)
controller = HardenedController(
handler,
ip_gate=ip_gate,
hostname=settings.PYMTA_SMTP_HOST,
port=settings.PYMTA_SMTP_PORT,
loop=asyncio.get_running_loop(),
)
# ``begin()`` is sync but only schedules; for a running loop we want to
# await ``_create_server`` directly so the loop drives it cleanly.
server = await controller._create_server() # noqa: SLF001
controller.server = server
logger.info(
"pymta SMTP listening on %s:%d (hostname=%s, proxy_protocol=%s, size=%d)",
settings.PYMTA_SMTP_HOST,
settings.PYMTA_SMTP_PORT,
settings.PYMTA_HOSTNAME,
settings.PYMTA_ENABLE_PROXY_PROTOCOL,
settings.MAX_INCOMING_EMAIL_SIZE,
)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, stop.set)
except NotImplementedError:
# Windows / restricted environments — no signal handler support.
pass
try:
await stop.wait()
finally:
logger.info("shutting down pymta SMTP listener")
server.close()
try:
await asyncio.wait_for(
server.wait_closed(), timeout=settings.PYMTA_SHUTDOWN_TIMEOUT
)
except TimeoutError:
logger.warning(
"graceful shutdown deadline (%ds) exceeded; in-flight "
"sessions abandoned",
settings.PYMTA_SHUTDOWN_TIMEOUT,
)
finally:
await mda_client.close()
def main() -> None:
_configure_logging()
metrics.start_metrics_server(settings.PYMTA_METRICS_HOST, settings.PYMTA_METRICS_PORT)
try:
asyncio.run(_serve())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
+175
View File
@@ -0,0 +1,175 @@
"""Environment-variable-driven settings for the pymta server."""
import os
def _env_bool(name: str, default: bool) -> bool:
raw = os.environ.get(name, "").strip().lower()
if raw in ("1", "true", "yes", "on"):
return True
if raw in ("0", "false", "no", "off"):
return False
return default
def _env_int(name: str, default: int) -> int:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return int(raw)
except ValueError as exc:
raise ValueError(f"Environment variable {name} must be an integer, got {raw!r}") from exc
def _env_str(name: str, default: str) -> str:
raw = os.environ.get(name)
if raw is None or raw == "":
return default
return raw
# ---------------------------------------------------------------------------
# MDA back-end (shared with the Postfix milter)
# ---------------------------------------------------------------------------
MDA_API_BASE_URL = _env_str("MDA_API_BASE_URL", "http://localhost:8000/api/v1.0/")
MDA_API_SECRET = _env_str("MDA_API_SECRET", "")
MDA_API_TIMEOUT = _env_int("MDA_API_TIMEOUT", 30)
# Circuit-breaker: when this many consecutive MDA calls fail (timeout / 5xx /
# transport error), pymta short-circuits subsequent calls for
# ``PYMTA_MDA_BREAKER_COOLDOWN`` seconds and replies 451 directly. Prevents
# SMTP sessions from stacking up against a dead MDA. Set to 0 to disable.
PYMTA_MDA_BREAKER_THRESHOLD = _env_int("PYMTA_MDA_BREAKER_THRESHOLD", 10)
PYMTA_MDA_BREAKER_COOLDOWN = _env_int("PYMTA_MDA_BREAKER_COOLDOWN", 30)
# ---------------------------------------------------------------------------
# SMTP listener
# ---------------------------------------------------------------------------
PYMTA_SMTP_HOST = _env_str("PYMTA_SMTP_HOST", "0.0.0.0") # noqa: S104
PYMTA_SMTP_PORT = _env_int("PYMTA_SMTP_PORT", 25)
# Banner / Received-header hostname. Matches Postfix's `myhostname`.
PYMTA_HOSTNAME = _env_str("PYMTA_HOSTNAME", _env_str("MYHOSTNAME", "mta-in"))
# ESMTP banner ident (after the hostname). Kept short and version-less so we
# don't broadcast "aiosmtpd X.Y.Z" to internet scanners.
PYMTA_IDENT = _env_str("PYMTA_IDENT", "ESMTP")
# ---------------------------------------------------------------------------
# Message-shape limits (security-critical)
# ---------------------------------------------------------------------------
# Total RFC822 message size cap. Mirrors Postfix `message_size_limit`.
MAX_INCOMING_EMAIL_SIZE = _env_int("MAX_INCOMING_EMAIL_SIZE", 10_240_000)
# RCPT TO per SMTP transaction. Mirrors Postfix `smtpd_recipient_limit=100`.
PYMTA_MAX_RECIPIENTS = _env_int("PYMTA_MAX_RECIPIENTS", 100)
# Envelopes per TCP connection (one envelope = MAIL FROM..DATA cycle).
PYMTA_MAX_ENVELOPES_PER_CONNECTION = _env_int("PYMTA_MAX_ENVELOPES_PER_CONNECTION", 10)
# RFC 5321 §4.5.3.1.1/.1.2: local-part ≤ 64 octets, domain ≤ 255 octets.
PYMTA_MAX_LOCAL_PART = _env_int("PYMTA_MAX_LOCAL_PART", 64)
PYMTA_MAX_DOMAIN = _env_int("PYMTA_MAX_DOMAIN", 255)
# ---------------------------------------------------------------------------
# Timeouts & connection caps
# ---------------------------------------------------------------------------
# Per-command idle timeout (seconds). Postfix default is 300 s; we tighten.
PYMTA_COMMAND_TIMEOUT = _env_int("PYMTA_COMMAND_TIMEOUT", 120)
# Total deadline for the DATA phase (seconds), wrapping the bytes-receive loop
# plus the MDA delivery call. Defends against slowloris on the body.
PYMTA_DATA_TIMEOUT = _env_int("PYMTA_DATA_TIMEOUT", 600)
# Maximum wall-clock seconds the server waits for in-flight sessions to drain
# after SIGTERM. Lower than k8s `terminationGracePeriodSeconds` so we exit
# cleanly before SIGKILL would interrupt an in-progress MDA deliver call.
PYMTA_SHUTDOWN_TIMEOUT = _env_int("PYMTA_SHUTDOWN_TIMEOUT", 25)
# Per-IP concurrent SMTP sessions. 0 disables the cap.
PYMTA_MAX_SESSIONS_PER_IP = _env_int("PYMTA_MAX_SESSIONS_PER_IP", 100)
# Process-wide concurrent SMTP sessions. 0 disables.
PYMTA_MAX_SESSIONS_TOTAL = _env_int("PYMTA_MAX_SESSIONS_TOTAL", 1000)
# Per-IP new-session rate, measured in a rolling 60s window. Defends against a
# peer that churns through fast open/close cycles (which never exceed the
# concurrent cap but still cost CPU/TLS handshakes/MDA RCPT checks). 0 disables.
PYMTA_MAX_SESSIONS_PER_IP_PER_MINUTE = _env_int("PYMTA_MAX_SESSIONS_PER_IP_PER_MINUTE", 600)
# Per-session soft-error budget. Mirrors Postfix `smtpd_hard_error_limit`:
# once a session accumulates this many 4xx/5xx replies (typically over-limit
# or unknown-recipient RCPTs), the next misbehaviour gets a 421 and the
# connection closes. Defends against bulk address enumeration that lives in
# one TCP session.
PYMTA_HARD_ERROR_LIMIT = _env_int("PYMTA_HARD_ERROR_LIMIT", 50)
# Per-session cap on unknown-mailbox lookups specifically. The hard-error
# budget above covers the *aggregate* of all 4xx/5xx replies; this one
# isolates enumeration: an attacker submitting valid-syntax addresses to
# probe which exist gets cut off after this many ``no such recipient``
# replies, even if the soft-error counter is still below its limit.
PYMTA_MAX_RCPT_MISSES_PER_SESSION = _env_int("PYMTA_MAX_RCPT_MISSES_PER_SESSION", 10)
# ---------------------------------------------------------------------------
# ESMTP feature toggles
# ---------------------------------------------------------------------------
PYMTA_ENABLE_SMTPUTF8 = _env_bool("PYMTA_ENABLE_SMTPUTF8", True)
# PROXY protocol v1/v2 (HAProxy in front). Mirrors the Postfix
# ENABLE_PROXY_PROTOCOL=haproxy env knob.
PYMTA_ENABLE_PROXY_PROTOCOL = _env_str(
"ENABLE_PROXY_PROTOCOL", ""
).lower() == "haproxy" or _env_bool("PYMTA_ENABLE_PROXY_PROTOCOL", False)
PYMTA_PROXY_PROTOCOL_TIMEOUT = _env_int("PYMTA_PROXY_PROTOCOL_TIMEOUT", 5)
# ---------------------------------------------------------------------------
# STARTTLS (opportunistic). When both files are set, STARTTLS is advertised.
#
# Two ways to configure STARTTLS:
# * pymta-native: ``PYMTA_TLS_CERT_FILE`` + ``PYMTA_TLS_KEY_FILE`` (two paths).
# * Postfix-style: ``STARTTLS_CHAIN_FILES`` — a comma-separated list of PEM
# bundle files (each bundle contains a private key followed by the cert
# chain). pymta reads the first bundle in the list and loads it via
# ``SSLContext.load_cert_chain(certfile=path, keyfile=path)``: Python's
# ssl module accepts a single combined PEM that way. Postfix-compatible.
# ---------------------------------------------------------------------------
PYMTA_TLS_CERT_FILE = _env_str("PYMTA_TLS_CERT_FILE", "")
PYMTA_TLS_KEY_FILE = _env_str("PYMTA_TLS_KEY_FILE", "")
# Postfix-style fallback. Only the first path in the comma-separated list is
# used (Postfix supports multiple for RSA+ECDSA dual-cert; pymta picks the
# first chain and lets the operator add SNI later if needed).
_chain_files = _env_str("STARTTLS_CHAIN_FILES", "")
if _chain_files and not PYMTA_TLS_CERT_FILE and not PYMTA_TLS_KEY_FILE:
_first_chain = _chain_files.split(",", 1)[0].strip()
PYMTA_TLS_CERT_FILE = _first_chain
PYMTA_TLS_KEY_FILE = _first_chain
# ---------------------------------------------------------------------------
# Prometheus metrics HTTP endpoint
# ---------------------------------------------------------------------------
PYMTA_METRICS_HOST = _env_str("PYMTA_METRICS_HOST", "0.0.0.0") # noqa: S104
# Set to 0 to disable the metrics HTTP server.
PYMTA_METRICS_PORT = _env_int("PYMTA_METRICS_PORT", 9100)
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
PYMTA_LOG_LEVEL = _env_str("PYMTA_LOG_LEVEL", "INFO").upper()
+133
View File
@@ -0,0 +1,133 @@
"""Hardened :class:`aiosmtpd.smtp.SMTP` subclass.
aiosmtpd's defaults are reasonable but its surface area still includes a few
verbs we never want exposed on a public, inbound-only port-25 endpoint:
* ``AUTH`` — never offered (no authenticator wired) but we still reply 502 to
reject any attempt explicitly, so a misconfiguration cannot quietly become a
relay.
* ``VRFY`` — RFC 5321 §3.5 lets us respond with a canned 252; we do so
unconditionally to prevent address enumeration.
* ``EXPN`` — explicit 502.
We also fold connection-admission control into :meth:`_handle_client`. The
gate is checked exactly once per accepted TCP session. When PROXY-protocol is
enabled, the check is deferred to the ``handle_PROXY`` hook so it sees the
real client address rather than the load-balancer's IP.
"""
from __future__ import annotations
import contextlib
import logging
import time
from aiosmtpd.smtp import SMTP as BaseSMTP
from . import metrics
from .limits import IPGate, TooManyConnections
logger = logging.getLogger(__name__)
class HardenedSMTP(BaseSMTP):
"""SMTP subclass that locks down VRFY/EXPN/AUTH and applies the IP gate."""
def __init__(self, *args, ip_gate: IPGate | None = None, **kwargs):
super().__init__(*args, **kwargs)
self._ip_gate: IPGate | None = ip_gate
# Track whether we are currently holding a slot in the gate so the
# release path runs at most once.
self._gate_held_ip: str | None = None
self._gate_started: float | None = None
# ----------------------------------------------------------- verb lockdown
async def smtp_VRFY(self, arg: str) -> None:
await self.push("252 2.1.5 Cannot VRFY user; try RCPT to verify")
async def smtp_EXPN(self, arg: str) -> None:
await self.push("502 5.7.0 EXPN disabled")
async def smtp_AUTH(self, arg: str) -> None:
# AUTH is never advertised (no authenticator wired, auth_require_tls
# defaults to True), but reply explicitly anyway so a misconfigured
# scanner cannot mistake an absent reply for acceptance.
await self.push("502 5.7.0 AUTH not supported on inbound port 25")
async def smtp_HELP(self, arg: str) -> None:
# Default aiosmtpd HELP enumerates implemented verbs (mild info leak).
await self.push("214 2.0.0 See https://www.rfc-editor.org/rfc/rfc5321")
# ------------------------------------------------------------ gate wiring
async def _handle_client(self) -> None:
"""Wrap aiosmtpd's per-connection dialogue with admission control.
Two paths:
* **No PROXY protocol** — the immediate TCP peer is the real client,
so we gate before the SMTP dialogue starts.
* **PROXY protocol enabled** — gate is deferred to
:meth:`acquire_gate_post_proxy`, called from the handler's
``handle_PROXY`` hook once the real client IP has been parsed off
the PROXY header.
"""
if self._ip_gate is not None and self._proxy_timeout is None:
if not await self._acquire_gate(self._wire_peer_ip()):
return
try:
await super()._handle_client()
finally:
await self._release_gate()
async def acquire_gate_post_proxy(self, real_ip: str) -> bool:
"""Acquire the gate using the IP parsed from a PROXY-protocol header.
Called from :meth:`pymta.handler.InboundHandler.handle_PROXY`. Returns
``True`` on success; on refusal sends 421 and closes the socket.
"""
if self._ip_gate is None:
return True
return await self._acquire_gate(real_ip)
async def _acquire_gate(self, ip: str) -> bool:
assert self._ip_gate is not None # noqa: S101 — narrowing only; checked above
try:
await self._ip_gate._try_acquire(ip) # noqa: SLF001
except TooManyConnections as exc:
metrics.CONNECTIONS_TOTAL.labels(result=f"rejected_{exc.scope}").inc()
metrics.DISCONNECTS_421.labels(reason=f"gate_{exc.scope}").inc()
logger.info("connection from %s refused: %s cap reached", ip, exc.scope)
with contextlib.suppress(OSError, ConnectionError):
await self.push("421 4.7.0 Too many connections, try again later")
# Best-effort drain so the 421 actually makes it out before the
# RST closes the socket.
if self._writer is not None:
await self._writer.drain()
if self.transport is not None:
self.transport.close()
return False
self._gate_held_ip = ip
self._gate_started = time.monotonic()
metrics.CONNECTIONS_TOTAL.labels(result="accepted").inc()
return True
async def _release_gate(self) -> None:
if self._gate_held_ip is None or self._ip_gate is None:
return
ip, self._gate_held_ip = self._gate_held_ip, None
if self._gate_started is not None:
metrics.SESSION_DURATION.observe(time.monotonic() - self._gate_started)
self._gate_started = None
# _handle_client's finally runs in the event loop, so awaiting the
# release is safe and avoids the fire-and-forget bookkeeping leak we
# would have with create_task during shutdown.
await self._ip_gate._release(ip) # noqa: SLF001
def _wire_peer_ip(self) -> str:
peer = getattr(self.session, "peer", None) if self.session else None
if peer:
return str(peer[0])
# All sessions without a wire peer collapse into one bucket; log so a
# spike here doesn't go invisible.
logger.warning("session has no transport peer; using 'unknown' bucket")
return "unknown"
+43 -17
View File
@@ -19,6 +19,17 @@ logger = logging.getLogger(__name__)
MDA_API_SECRET = os.getenv("MDA_API_SECRET") MDA_API_SECRET = os.getenv("MDA_API_SECRET")
MTA_HOST = os.getenv("MTA_HOST") MTA_HOST = os.getenv("MTA_HOST")
MTA_PORT = int(os.getenv("MTA_PORT", "25"))
# When MTA_METRICS_URL is set (only by the pymta test runner) the metrics
# tests in tests/test_metrics.py become exercisable. The Postfix-based
# implementation has no Prometheus endpoint, so those tests skip on it.
MTA_METRICS_URL = os.getenv("MTA_METRICS_URL")
# Tag tests with the implementation under test, exposed through the
# `mta_impl` fixture below. Useful for skipping the few tests that assert
# implementation-specific behaviour (e.g. metrics-shape).
MTA_IMPL = os.getenv("MTA_IMPL", "postfix")
class MockAPIServer: class MockAPIServer:
@@ -153,31 +164,46 @@ def mock_api_server():
server.stop() server.stop()
@pytest.fixture def _wait_for_mta(host: str, port: int, retries: int = 100) -> None:
def smtp_client(): for attempt in range(retries):
# Wait for Postfix to be ready
max_retries = 100
for attempt in range(max_retries):
try: try:
# First check if port is open
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((MTA_HOST, 25)) s.settimeout(1)
s.connect((host, port))
# Then try SMTP connection return
client = smtplib.SMTP(MTA_HOST, 25) except (ConnectionRefusedError, OSError) as e:
logger.info("SMTP connection established") if attempt == retries - 1:
break
except (ConnectionRefusedError, smtplib.SMTPConnectError, socket.error) as e:
if attempt == max_retries - 1:
raise raise
if attempt % 20 == 0: if attempt % 20 == 0:
logger.warning( logger.warning("SMTP port %s:%s not ready (%s); retrying...", host, port, e)
f"SMTP connection attempt {attempt + 1} failed ({str(e)}), retrying in 1s..."
)
time.sleep(0.1) time.sleep(0.1)
@pytest.fixture
def smtp_client():
_wait_for_mta(MTA_HOST, MTA_PORT)
client = smtplib.SMTP(MTA_HOST, MTA_PORT)
logger.info("SMTP connection established to %s:%s", MTA_HOST, MTA_PORT)
yield client yield client
try: try:
client.quit() client.quit()
except smtplib.SMTPServerDisconnected: except smtplib.SMTPServerDisconnected:
pass pass
@pytest.fixture
def mta_impl() -> str:
"""Identifier for the implementation under test: ``postfix`` or ``pymta``."""
return MTA_IMPL
@pytest.fixture
def mta_address() -> tuple[str, int]:
"""(host, port) of the inbound MTA under test."""
return (MTA_HOST, MTA_PORT)
@pytest.fixture
def mta_metrics_url() -> str | None:
"""URL to scrape for Prometheus metrics; None if unavailable."""
return MTA_METRICS_URL
+241
View File
@@ -0,0 +1,241 @@
"""Unit tests for :mod:`pymta.address`.
Exercises every rejection branch in ``validate_envelope_address`` plus the
positive happy paths. Pure-stdlib — no Docker stack needed.
"""
from __future__ import annotations
import pytest
from pymta.address import AddressError, strip_brackets, validate_envelope_address
def _validate(address: str, *, allow_empty: bool = False) -> str:
return validate_envelope_address(
address, allow_empty=allow_empty, max_local=64, max_domain=255
)
# ---------------------------------------------------------------------------
# strip_brackets
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("raw", "expected"),
[
("<user@example.com>", "user@example.com"),
("user@example.com", "user@example.com"),
("", ""),
("<>", ""),
],
)
def test_strip_brackets(raw, expected):
assert strip_brackets(raw) == expected
# ---------------------------------------------------------------------------
# Happy paths
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"address",
[
"user@example.com",
"<user@example.com>",
"user.name@example.com",
"user+tag@sub.example.com",
"a@b.co",
"user@xn--bcher-kva.example", # IDN A-label
"user@münchen.de", # raw UTF-8 domain (SMTPUTF8)
],
)
def test_valid_addresses_accepted(address):
out = _validate(address)
assert "@" in out
def test_domain_is_lowercased():
assert _validate("User@EXAMPLE.COM") == "User@example.com"
def test_local_part_case_is_preserved():
# Per RFC 5321 §2.3.11 local-parts are case-sensitive on the wire; we
# leave the decision to the MDA's normalisation rules.
assert _validate("User.Name@example.com").startswith("User.Name@")
def test_null_sender_allowed_when_enabled():
assert _validate("", allow_empty=True) == ""
assert _validate("<>", allow_empty=True) == ""
def test_null_sender_rejected_for_rcpt():
with pytest.raises(AddressError) as exc:
_validate("", allow_empty=False)
assert exc.value.reason == "bad_address"
assert exc.value.smtp_code == 553
# ---------------------------------------------------------------------------
# Residual / unbalanced angle brackets
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"address",
[
"<<user@example.com>>",
"<user@example.com",
"user@example.com>",
"<user@example.com>extra",
],
)
def test_residual_brackets_rejected(address):
with pytest.raises(AddressError) as exc:
_validate(address)
assert exc.value.reason == "bad_address"
assert exc.value.smtp_code == 501
# ---------------------------------------------------------------------------
# Control characters / CRLF injection
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"address",
[
"user\r@example.com",
"user\n@example.com",
"user\x00@example.com",
"user\t@example.com",
"user @example.com",
"user%@example.com",
"user\x7f@example.com",
],
)
def test_control_chars_rejected(address):
with pytest.raises(AddressError) as exc:
_validate(address)
assert exc.value.reason == "control_char"
assert exc.value.smtp_code == 501
# ---------------------------------------------------------------------------
# Source routes (RFC 5321 §4.1.1.3)
# ---------------------------------------------------------------------------
def test_source_route_rejected():
with pytest.raises(AddressError) as exc:
_validate("@host1.example,@host2.example:user@host3.example")
assert exc.value.reason == "source_route"
# ---------------------------------------------------------------------------
# Quoted local-parts
# ---------------------------------------------------------------------------
def test_quoted_local_part_rejected():
# The space-in-quote form would be caught earlier by the control-char
# check; use a tame quoted form so we land on the quoted-local-part rule.
with pytest.raises(AddressError) as exc:
_validate('"weird"@example.com')
assert exc.value.reason == "bad_address"
assert exc.value.smtp_code == 553
# ---------------------------------------------------------------------------
# Dot-placement in unquoted local-part
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"address",
[
".user@example.com",
"user.@example.com",
"user..name@example.com",
],
)
def test_bad_dot_placement_rejected(address):
with pytest.raises(AddressError) as exc:
_validate(address)
assert exc.value.reason == "bad_address"
# ---------------------------------------------------------------------------
# @-count, missing local/domain
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"address",
[
"noatsign",
"two@@example.com",
"user@host@other",
"@example.com", # caught earlier as source_route — covered above
"user@",
],
)
def test_malformed_at_or_missing_parts_rejected(address):
with pytest.raises(AddressError):
_validate(address)
# ---------------------------------------------------------------------------
# Length limits
# ---------------------------------------------------------------------------
def test_overlong_local_part_rejected():
long_local = "a" * 65
with pytest.raises(AddressError) as exc:
_validate(f"{long_local}@example.com")
assert exc.value.reason == "oversize_local"
def test_overlong_domain_rejected():
# 64-char label * 4 + dots = ~260 chars (>255 total domain cap).
long_domain = ".".join(["a" * 60] * 5)
with pytest.raises(AddressError) as exc:
_validate(f"user@{long_domain}")
assert exc.value.reason == "oversize_domain"
def test_overlong_label_rejected():
too_long_label = "a" * 64 # one octet over RFC 1035 §2.3.4
with pytest.raises(AddressError) as exc:
_validate(f"user@{too_long_label}.example")
assert exc.value.reason == "bad_address"
# ---------------------------------------------------------------------------
# Domain shape
# ---------------------------------------------------------------------------
def test_address_literal_rejected_with_dedicated_reason():
with pytest.raises(AddressError) as exc:
_validate("user@[192.0.2.1]")
assert exc.value.reason == "address_literal"
assert "literal" in exc.value.smtp_text.lower()
@pytest.mark.parametrize(
"domain",
[
".example.com",
"example.com.",
"example..com",
".",
],
)
def test_malformed_domain_rejected(domain):
with pytest.raises(AddressError):
_validate(f"user@{domain}")
+9 -3
View File
@@ -46,7 +46,9 @@ def test_simple_email_delivery(mock_api_server, smtp_client):
assert not email["email"].is_multipart() assert not email["email"].is_multipart()
body = email["email"].get_payload() body = email["email"].get_payload()
# TODO: why the \n ? # smtplib converts the bare \n in the source string to the SMTP-mandatory
# CRLF (\r\n) during DATA transmission, so the body received by the MDA
# carries \r\n line endings — not the \n we put in the MIMEText source.
assert body == "This is a test email\r\n" assert body == "This is a test email\r\n"
@@ -91,7 +93,9 @@ def test_simple_email_delivery_with_multiple_recipients(mock_api_server, smtp_cl
assert not email["email"].is_multipart() assert not email["email"].is_multipart()
body = email["email"].get_payload() body = email["email"].get_payload()
# TODO: why the \n ? # smtplib converts the bare \n in the source string to the SMTP-mandatory
# CRLF (\r\n) during DATA transmission, so the body received by the MDA
# carries \r\n line endings — not the \n we put in the MIMEText source.
assert body == "This is a test email\r\n" assert body == "This is a test email\r\n"
mock_api_server.received_emails = [] mock_api_server.received_emails = []
@@ -119,7 +123,9 @@ def test_simple_email_delivery_with_multiple_recipients(mock_api_server, smtp_cl
assert not email["email"].is_multipart() assert not email["email"].is_multipart()
body = email["email"].get_payload() body = email["email"].get_payload()
# TODO: why the \n ? # smtplib converts the bare \n in the source string to the SMTP-mandatory
# CRLF (\r\n) during DATA transmission, so the body received by the MDA
# carries \r\n line endings — not the \n we put in the MIMEText source.
assert body == "This is a test email\r\n" assert body == "This is a test email\r\n"
+182
View File
@@ -0,0 +1,182 @@
"""Unit tests for :mod:`pymta.handler`.
These tests cover the *session-state* invariants of the handler (counter
bumps, gate paths). They run the handler against fake session/envelope/MDA
stand-ins — no Docker stack, no real SMTP traffic.
"""
from __future__ import annotations
import types
import pytest
from pymta import settings
from pymta.handler import (
_RCPT_MISSES_ATTR,
_SOFT_ERRORS_ATTR,
InboundHandler,
NULL_SENDER_SENTINEL,
)
from pymta.mda_async import MDAResult
class _FakeMDA:
"""Stand-in for MDAClient — returns whatever the test wires up."""
def __init__(self, check_result: MDAResult | None = None):
self.check_result = check_result or MDAResult(
ok=True, temp_fail=False, payload={}, status_code=200
)
async def check_recipient(self, address: str) -> MDAResult:
return self.check_result
def _session():
return types.SimpleNamespace(host_name=None, peer=("203.0.113.5", 12345))
def _envelope():
return types.SimpleNamespace(
mail_from=None, rcpt_tos=[], mail_options=[], rcpt_options=[], content=b""
)
def _handler(mda=None) -> InboundHandler:
return InboundHandler(mda or _FakeMDA())
# ---------------------------------------------------------------------------
# MAIL SIZE= path bumps the soft-error counter on both rejection branches.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mail_size_bad_value_bumps_soft_errors():
session, envelope = _session(), _envelope()
reply = await _handler().handle_MAIL(
None, session, envelope, "<a@example.com>", ["SIZE=not-a-number"]
)
assert reply.startswith("501")
assert getattr(session, _SOFT_ERRORS_ATTR) == 1
@pytest.mark.asyncio
async def test_mail_size_oversize_bumps_soft_errors():
session, envelope = _session(), _envelope()
too_big = settings.MAX_INCOMING_EMAIL_SIZE + 1
reply = await _handler().handle_MAIL(
None, session, envelope, "<a@example.com>", [f"SIZE={too_big}"]
)
assert reply.startswith("552")
assert getattr(session, _SOFT_ERRORS_ATTR) == 1
# ---------------------------------------------------------------------------
# DATA negative paths bump the soft-error counter.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_data_nul_byte_bumps_soft_errors():
session, envelope = _session(), _envelope()
envelope.content = b"Subject: x\r\n\r\nhello\x00world\r\n"
reply = await _handler().handle_DATA(None, session, envelope)
assert reply.startswith("554")
assert getattr(session, _SOFT_ERRORS_ATTR) == 1
@pytest.mark.asyncio
async def test_data_oversize_bumps_soft_errors():
session, envelope = _session(), _envelope()
envelope.content = b"x" * (settings.MAX_INCOMING_EMAIL_SIZE + 10)
reply = await _handler().handle_DATA(None, session, envelope)
assert reply.startswith("552")
assert getattr(session, _SOFT_ERRORS_ATTR) == 1
@pytest.mark.asyncio
async def test_data_max_envelopes_bumps_soft_errors():
session, envelope = _session(), _envelope()
setattr(session, "_pymta_envelopes", settings.PYMTA_MAX_ENVELOPES_PER_CONNECTION)
reply = await _handler().handle_DATA(None, session, envelope)
assert reply.startswith("451")
assert getattr(session, _SOFT_ERRORS_ATTR) == 1
# ---------------------------------------------------------------------------
# RCPT miss counter / dedicated cutoff (S3).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rcpt_miss_counter_triggers_421_at_limit(monkeypatch):
# Tight limit so we don't have to do many round-trips.
monkeypatch.setattr(settings, "PYMTA_MAX_RCPT_MISSES_PER_SESSION", 3)
mda = _FakeMDA(
check_result=MDAResult(
ok=True, temp_fail=False, payload={}, status_code=200
) # exists=False for every address
)
handler, session, envelope = _handler(mda), _session(), _envelope()
# First two misses get the normal 550.
for i in range(2):
reply = await handler.handle_RCPT(
None, session, envelope, f"<miss{i}@example.com>", []
)
assert reply.startswith("550"), reply
# Third miss hits the per-session cap and forces 421.
reply = await handler.handle_RCPT(
None, session, envelope, "<miss3@example.com>", []
)
assert reply.startswith("421")
assert getattr(session, _RCPT_MISSES_ATTR) == 3
@pytest.mark.asyncio
async def test_rcpt_existence_does_not_increment_miss_counter():
mda = _FakeMDA(
check_result=MDAResult(
ok=True,
temp_fail=False,
payload={"hit@example.com": True},
status_code=200,
)
)
handler, session, envelope = _handler(mda), _session(), _envelope()
reply = await handler.handle_RCPT(
None, session, envelope, "<hit@example.com>", []
)
assert reply.startswith("250")
assert getattr(session, _RCPT_MISSES_ATTR, 0) == 0
# ---------------------------------------------------------------------------
# Hard-error budget cutoff still fires from the existing gate.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_hard_error_limit_blocks_further_rcpts(monkeypatch):
monkeypatch.setattr(settings, "PYMTA_HARD_ERROR_LIMIT", 2)
handler, session, envelope = _handler(), _session(), _envelope()
setattr(session, _SOFT_ERRORS_ATTR, 2)
reply = await handler.handle_RCPT(
None, session, envelope, "<anyone@example.com>", []
)
assert reply.startswith("421")
# ---------------------------------------------------------------------------
# Null sender survives the round-trip via the sentinel.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_null_sender_round_trip_via_sentinel():
session, envelope = _session(), _envelope()
reply = await _handler().handle_MAIL(None, session, envelope, "<>", [])
assert reply.startswith("250")
assert envelope.mail_from == NULL_SENDER_SENTINEL
+195
View File
@@ -0,0 +1,195 @@
"""Unit tests for :class:`pymta.limits.IPGate`.
Unlike the rest of the suite in this directory, these tests do NOT need a
running MTA — they exercise the gate object directly. They run via the same
``test-mta-in-py`` target but skip the SMTP integration fixtures.
"""
from __future__ import annotations
import pytest
from pymta.limits import IPGate, TooManyConnections
class _FakeClock:
"""Manually-advanced monotonic clock for deterministic rate-window tests."""
def __init__(self, start: float = 1000.0):
self.now = start
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
# ---------------------------------------------------------------------------
# Concurrent-cap behaviour (existing semantics).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_global_cap_blocks_when_total_reached():
gate = IPGate(max_total=2, max_per_ip=0)
await gate._try_acquire("1.1.1.1")
await gate._try_acquire("2.2.2.2")
with pytest.raises(TooManyConnections) as exc:
await gate._try_acquire("3.3.3.3")
assert exc.value.scope == "global"
await gate._release("1.1.1.1")
await gate._release("2.2.2.2")
@pytest.mark.asyncio
async def test_per_ip_cap_blocks_same_ip_only():
gate = IPGate(max_total=0, max_per_ip=2)
await gate._try_acquire("1.1.1.1")
await gate._try_acquire("1.1.1.1")
with pytest.raises(TooManyConnections) as exc:
await gate._try_acquire("1.1.1.1")
assert exc.value.scope == "per_ip"
# A different IP is still admitted.
await gate._try_acquire("2.2.2.2")
await gate._release("1.1.1.1")
await gate._release("1.1.1.1")
await gate._release("2.2.2.2")
@pytest.mark.asyncio
async def test_release_frees_slot_for_same_ip():
gate = IPGate(max_total=0, max_per_ip=1)
await gate._try_acquire("1.1.1.1")
with pytest.raises(TooManyConnections):
await gate._try_acquire("1.1.1.1")
await gate._release("1.1.1.1")
# Slot freed — next acquire from the same IP succeeds.
await gate._try_acquire("1.1.1.1")
await gate._release("1.1.1.1")
@pytest.mark.asyncio
async def test_zero_disables_concurrency_caps():
gate = IPGate(max_total=0, max_per_ip=0)
# Loopback test harness traffic comes from one IP; we must not throttle it.
for _ in range(50):
await gate._try_acquire("127.0.0.1")
for _ in range(50):
await gate._release("127.0.0.1")
@pytest.mark.asyncio
async def test_rate_cap_blocks_after_quota_in_window():
clock = _FakeClock()
gate = IPGate(max_total=0, max_per_ip=0, max_per_ip_per_minute=3, clock=clock)
# Three quick session acquires from one IP — all release immediately so the
# concurrent cap can't be the thing blocking us; only the rate cap is.
for _ in range(3):
await gate._try_acquire("1.1.1.1")
await gate._release("1.1.1.1")
with pytest.raises(TooManyConnections) as exc:
await gate._try_acquire("1.1.1.1")
assert exc.value.scope == "per_ip_rate"
@pytest.mark.asyncio
async def test_rate_window_resets_after_60_seconds():
clock = _FakeClock()
gate = IPGate(max_total=0, max_per_ip=0, max_per_ip_per_minute=2, clock=clock)
await gate._try_acquire("1.1.1.1")
await gate._release("1.1.1.1")
await gate._try_acquire("1.1.1.1")
await gate._release("1.1.1.1")
with pytest.raises(TooManyConnections):
await gate._try_acquire("1.1.1.1")
# Window closes — fresh budget.
clock.advance(60.1)
await gate._try_acquire("1.1.1.1")
await gate._release("1.1.1.1")
await gate._try_acquire("1.1.1.1")
@pytest.mark.asyncio
async def test_rate_cap_is_per_ip_not_global():
clock = _FakeClock()
gate = IPGate(max_total=0, max_per_ip=0, max_per_ip_per_minute=2, clock=clock)
await gate._try_acquire("1.1.1.1")
await gate._release("1.1.1.1")
await gate._try_acquire("1.1.1.1")
await gate._release("1.1.1.1")
# A second IP gets its own bucket — must not be tarred by 1.1.1.1's spend.
await gate._try_acquire("2.2.2.2")
await gate._release("2.2.2.2")
@pytest.mark.asyncio
async def test_rate_cap_disabled_when_zero():
clock = _FakeClock()
gate = IPGate(max_total=0, max_per_ip=0, max_per_ip_per_minute=0, clock=clock)
for _ in range(50):
await gate._try_acquire("1.1.1.1")
await gate._release("1.1.1.1")
# And the rate-tracking dict stays empty so loopback dev/test isn't
# paying memory for a feature it never uses.
assert gate._rate_per_ip == {}
@pytest.mark.asyncio
async def test_rate_dict_prunes_expired_entries():
"""The rate map must not grow without bound under churning client IPs."""
clock = _FakeClock()
from pymta import limits
# Shrink the prune interval so the test doesn't have to call 1000 times.
original = limits._RATE_PRUNE_EVERY
limits._RATE_PRUNE_EVERY = 10
try:
gate = IPGate(max_total=0, max_per_ip=0, max_per_ip_per_minute=1, clock=clock)
for i in range(9):
await gate._try_acquire(f"10.0.0.{i}")
await gate._release(f"10.0.0.{i}")
assert len(gate._rate_per_ip) == 9
# All previous windows expire.
clock.advance(61.0)
# The 10th acquire triggers the prune sweep.
await gate._try_acquire("10.0.0.99")
await gate._release("10.0.0.99")
# Only the most recent entry survives; all stale ones are gone.
assert set(gate._rate_per_ip.keys()) == {"10.0.0.99"}
finally:
limits._RATE_PRUNE_EVERY = original
# ---------------------------------------------------------------------------
# Rate cap interacts cleanly with the concurrent caps.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_global_cap_takes_precedence_over_rate_cap():
# When both would fire, the cheaper-to-evaluate global check should win
# so we don't bother accounting against the rate bucket for a session we
# were never going to admit anyway.
clock = _FakeClock()
gate = IPGate(max_total=1, max_per_ip=0, max_per_ip_per_minute=10, clock=clock)
await gate._try_acquire("1.1.1.1")
with pytest.raises(TooManyConnections) as exc:
await gate._try_acquire("2.2.2.2")
assert exc.value.scope == "global"
# The refused acquire must not have consumed 2.2.2.2's rate budget.
assert "2.2.2.2" not in gate._rate_per_ip
await gate._release("1.1.1.1")
@pytest.mark.asyncio
async def test_per_ip_concurrent_cap_takes_precedence_over_rate_cap():
clock = _FakeClock()
gate = IPGate(max_total=0, max_per_ip=1, max_per_ip_per_minute=10, clock=clock)
await gate._try_acquire("1.1.1.1")
with pytest.raises(TooManyConnections) as exc:
await gate._try_acquire("1.1.1.1")
assert exc.value.scope == "per_ip"
# Only the first (admitted) acquire should have been billed to the bucket.
assert gate._rate_per_ip["1.1.1.1"][0] == 1
await gate._release("1.1.1.1")
+231
View File
@@ -0,0 +1,231 @@
"""Unit tests for :class:`pymta.mda_async.MDAClient`.
Stubs ``httpx.AsyncClient.post`` to drive every code path (timeout, transport
error, 5xx, 4xx, 200) and to verify the circuit breaker opens / resets as
expected. No real HTTP traffic, no Docker stack.
"""
from __future__ import annotations
import httpx
import pytest
from pymta.mda_async import MDAClient
class _FakeClock:
def __init__(self, start: float = 1000.0):
self.now = start
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
class _StubAsyncClient:
"""Stand-in for ``httpx.AsyncClient`` driven by a script of responses."""
def __init__(self, script):
self.script = list(script)
self.calls = 0
async def post(self, url, content=None, headers=None):
self.calls += 1
action = self.script.pop(0) if self.script else None
if isinstance(action, Exception):
raise action
return action
async def aclose(self):
pass
def _resp(status_code: int, body: bytes = b'{"ok": true}'):
return httpx.Response(status_code=status_code, content=body)
def _new_client(*, secret: str = "x" * 32, threshold: int = 3, cooldown: int = 30):
"""Construct an MDAClient wired to fakes — no settings module mutation."""
clock = _FakeClock()
client = MDAClient(
base_url="https://mda.example.invalid/api/",
secret=secret,
timeout=5,
breaker_threshold=threshold,
breaker_cooldown=cooldown,
clock=clock,
)
return client, clock
# ---------------------------------------------------------------------------
# Single-shot result classification
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_timeout_returns_temp_fail():
client, _ = _new_client()
client._client = _StubAsyncClient([httpx.TimeoutException("slow")])
result = await client.check_recipient("user@example.com")
assert result.ok is False
assert result.temp_fail is True
assert result.status_code == 0
@pytest.mark.asyncio
async def test_transport_error_returns_temp_fail():
client, _ = _new_client()
client._client = _StubAsyncClient([httpx.ConnectError("no route")])
result = await client.check_recipient("user@example.com")
assert result.temp_fail is True
@pytest.mark.asyncio
async def test_5xx_returns_temp_fail():
client, _ = _new_client()
client._client = _StubAsyncClient([_resp(503, b'{"detail":"upstream"}')])
result = await client.check_recipient("user@example.com")
assert result.temp_fail is True
assert result.status_code == 503
@pytest.mark.asyncio
async def test_4xx_returns_perm_fail():
client, _ = _new_client()
client._client = _StubAsyncClient([_resp(404, b'{"detail":"no"}')])
result = await client.check_recipient("user@example.com")
assert result.ok is False
assert result.temp_fail is False
assert result.status_code == 404
@pytest.mark.asyncio
async def test_200_returns_ok_with_payload():
client, _ = _new_client()
client._client = _StubAsyncClient([_resp(200, b'{"user@example.com": true}')])
result = await client.check_recipient("user@example.com")
assert result.ok is True
assert result.payload == {"user@example.com": True}
# ---------------------------------------------------------------------------
# Circuit breaker
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_breaker_opens_after_threshold_consecutive_failures():
client, clock = _new_client(threshold=3, cooldown=30)
client._client = _StubAsyncClient(
[httpx.TimeoutException("a"), httpx.TimeoutException("b"), httpx.TimeoutException("c")]
)
for _ in range(3):
result = await client.check_recipient("a@b")
assert result.temp_fail is True
# Breaker should now be open — the next call must NOT hit the network.
stub = client._client
result = await client.check_recipient("a@b")
assert result.temp_fail is True
assert stub.calls == 3, "breaker did not short-circuit"
@pytest.mark.asyncio
async def test_breaker_closes_after_cooldown():
client, clock = _new_client(threshold=2, cooldown=30)
client._client = _StubAsyncClient(
[httpx.TimeoutException("a"), httpx.TimeoutException("b"), _resp(200)]
)
await client.check_recipient("a@b")
await client.check_recipient("a@b")
# Breaker open — fast-fail.
await client.check_recipient("a@b")
assert client._open_until is not None
# Advance past cooldown — next call probes the network.
clock.advance(31.0)
result = await client.check_recipient("a@b")
assert result.ok is True
assert client._open_until is None
assert client._consecutive_failures == 0
@pytest.mark.asyncio
async def test_success_resets_failure_counter():
client, _ = _new_client(threshold=10)
client._client = _StubAsyncClient(
[httpx.TimeoutException("a"), httpx.TimeoutException("b"), _resp(200)]
)
await client.check_recipient("a@b")
await client.check_recipient("a@b")
assert client._consecutive_failures == 2
await client.check_recipient("a@b")
assert client._consecutive_failures == 0
@pytest.mark.asyncio
async def test_4xx_does_not_count_as_breaker_failure():
# 4xx means the MDA understood our request and rejected it — that's not a
# liveness signal worth tripping the breaker.
client, _ = _new_client(threshold=2)
client._client = _StubAsyncClient([_resp(404), _resp(404), _resp(404)])
for _ in range(3):
await client.check_recipient("a@b")
assert client._consecutive_failures == 0
assert client._open_until is None
@pytest.mark.asyncio
async def test_breaker_disabled_when_threshold_zero():
client, _ = _new_client(threshold=0)
client._client = _StubAsyncClient([httpx.TimeoutException("a")] * 5)
for _ in range(5):
await client.check_recipient("a@b")
assert client._open_until is None
# ---------------------------------------------------------------------------
# Credential / URL hygiene warnings (S1 + S2).
# ---------------------------------------------------------------------------
def test_non_local_http_url_logs_warning(caplog):
caplog.set_level("WARNING")
MDAClient(base_url="http://mda.example.com/api/", secret="x" * 32)
assert any("plaintext" in rec.message for rec in caplog.records)
def test_short_secret_logs_warning(caplog):
caplog.set_level("WARNING")
MDAClient(base_url="https://mda.example.com/api/", secret="too-short")
assert any("MDA_API_SECRET" in rec.message for rec in caplog.records)
def test_local_http_url_is_silent(caplog):
caplog.set_level("WARNING")
MDAClient(base_url="http://127.0.0.1:8000/api/", secret="x" * 32)
assert not any("plaintext" in rec.message for rec in caplog.records)
# ---------------------------------------------------------------------------
# JWT claim ordering (B1).
# ---------------------------------------------------------------------------
def test_metadata_cannot_shadow_exp_or_body_hash():
import jwt
client, _ = _new_client()
body = b"hello"
# Attacker-supplied metadata tries to overwrite security fields.
token = client._build_jwt(
body, {"exp": 0, "body_hash": "deadbeef", "sender": "u@x"}
)
decoded = jwt.decode(token, client.secret, algorithms=["HS256"])
# The real exp must be in the future, not 0.
assert decoded["exp"] != 0
# The real body_hash must be the sha256 of `body`, not "deadbeef".
assert decoded["body_hash"] != "deadbeef"
# Sender (non-conflicting metadata) survives.
assert decoded["sender"] == "u@x"
+86
View File
@@ -0,0 +1,86 @@
"""Prometheus metrics tests — pymta-only.
Skipped automatically when ``MTA_METRICS_URL`` is not set (i.e. when running
against the Postfix implementation, which has no Prometheus endpoint).
"""
import logging
import smtplib
import urllib.request
from email.mime.text import MIMEText
import pytest
logger = logging.getLogger(__name__)
def _scrape(url: str) -> str:
with urllib.request.urlopen(url, timeout=5) as resp:
return resp.read().decode("utf-8", errors="replace")
def _metric_value(scrape_text: str, prefix: str) -> float:
"""Sum every series that begins with ``prefix``, return the total.
A ``prefix`` like ``pymta_messages_total{result="delivered"}`` matches a
single series. ``pymta_messages_total`` (no label selector) matches
every series of that metric.
"""
total = 0.0
for line in scrape_text.splitlines():
if line.startswith("#") or not line.strip():
continue
# Format: `name{labels} value [timestamp]` or `name value`
if line.startswith(prefix):
parts = line.split()
# 2 tokens → name value; 3+ tokens → name value timestamp.
value = parts[-2] if len(parts) >= 3 else parts[-1]
try:
total += float(value)
except ValueError:
continue
return total
@pytest.fixture
def metrics_url(mta_metrics_url):
if not mta_metrics_url:
pytest.skip("MTA_METRICS_URL not set (only the pymta image exposes metrics)")
return mta_metrics_url
def test_metrics_endpoint_reachable(metrics_url):
text = _scrape(metrics_url)
assert "pymta_connections_total" in text
assert "pymta_messages_total" in text
def test_delivery_increments_messages_total(metrics_url, mock_api_server, smtp_client):
mock_api_server.add_mailbox("metrics-test@example.com")
before = _metric_value(_scrape(metrics_url), 'pymta_messages_total{result="delivered"}')
msg = MIMEText("metrics body\n")
msg["From"] = "sender@example.com"
msg["To"] = "metrics-test@example.com"
msg["Subject"] = "metrics"
smtp_client.send_message(msg)
mock_api_server.wait_for_email()
after = _metric_value(_scrape(metrics_url), 'pymta_messages_total{result="delivered"}')
assert after >= before + 1, (before, after)
def test_rcpt_rejected_increments_rcpt_total(metrics_url, mock_api_server, smtp_client):
before = _metric_value(_scrape(metrics_url), 'pymta_rcpt_total{result="rejected_perm"}')
# An RCPT that the MDA does not know about → permanent reject.
msg = MIMEText("body\n")
msg["From"] = "sender@example.com"
msg["To"] = "unknown-metrics@example.com"
msg["Subject"] = "rejected"
with pytest.raises(smtplib.SMTPRecipientsRefused):
smtp_client.send_message(msg)
after = _metric_value(_scrape(metrics_url), 'pymta_rcpt_total{result="rejected_perm"}')
assert after >= before + 1, (before, after)
+381
View File
@@ -0,0 +1,381 @@
"""Security / hardening tests, runnable against both the Postfix milter and
the pure-Python pymta implementations.
These exercise the attack classes the inbound MTA is most exposed to on the
public internet:
* SMTP smuggling (bare-LF / bare-CR EOD smuggling, RFC 5321 §2.3.8)
* CRLF / control-character injection in envelope addresses
* NUL bytes in DATA
* Source routes (RFC 5321 §4.1.1.3)
* VRFY / EXPN information disclosure
* AUTH leakage on port 25
* Overlong local-parts / domains
* Pre-DATA SIZE oversize announcement
* Pipelined-command-before-banner (smuggling helper)
* Hard error limit / command flood
* Line-length cap
Where the two implementations have different SMTP codes (Postfix uses some
504/521 codes pymta uses 502 for), tests assert on the response *class*
(2xx/4xx/5xx) instead of an exact code.
"""
import logging
import os
import socket
import pytest
logger = logging.getLogger(__name__)
MTA_HOST = os.getenv("MTA_HOST")
MTA_PORT = int(os.getenv("MTA_PORT", "25"))
def _raw_session(timeout: float = 5):
"""Open a raw socket to the MTA, swallow the banner, and return it."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
s.connect((MTA_HOST, MTA_PORT))
banner = _read_reply(s)
assert banner.startswith(b"220"), banner
return s
def _read_reply(s: socket.socket, max_bytes: int = 65536) -> bytes:
"""Read one full SMTP reply, handling multi-line continuations.
SMTP multi-line replies use ``xxx-`` on every line except the last,
which uses ``xxx <space>``. We accumulate bytes until either:
* the buffer ends with a final line ``xxx <SP>...<CRLF>``, or
* the server closes the connection (yielding partial bytes).
"""
buf = b""
while len(buf) < max_bytes:
try:
chunk = s.recv(4096)
except socket.timeout:
break
if not chunk:
break
buf += chunk
# Check the last full line for the "final" marker (digit + space).
lines = buf.splitlines()
last = lines[-1] if lines else b""
if buf.endswith(b"\r\n") and len(last) >= 4 and last[3:4] == b" ":
break
return buf
def _send_cmd(s: socket.socket, cmd: bytes) -> bytes:
s.sendall(cmd)
return _read_reply(s)
# ---------------------------------------------------------------------------
# 1. AUTH must NEVER be offered on port 25 (inbound, no submission).
# ---------------------------------------------------------------------------
def test_auth_not_advertised_in_ehlo():
s = _raw_session()
try:
resp = _send_cmd(s, b"EHLO example.com\r\n")
text = resp.decode("ascii", errors="replace").upper()
assert "AUTH" not in text, f"AUTH advertised on port 25!\n{text}"
finally:
s.close()
def test_auth_command_rejected():
s = _raw_session()
try:
_send_cmd(s, b"EHLO example.com\r\n")
resp = _send_cmd(s, b"AUTH LOGIN\r\n")
# Must NOT be 235 (auth success) or 334 (continue), even on accident.
# Both 502 (not implemented) and 503 (bad sequence) are acceptable.
assert resp[:3] in (b"502", b"503", b"500"), resp
finally:
s.close()
# ---------------------------------------------------------------------------
# 2. VRFY/EXPN address enumeration must be neutered.
# ---------------------------------------------------------------------------
def test_vrfy_does_not_confirm_existence():
# No mock_api_server / mailbox setup needed — VRFY must reply identically
# whether the address exists or not. If it ever leaked existence the
# answer would differ even before the MDA is consulted.
s = _raw_session()
try:
_send_cmd(s, b"EHLO example.com\r\n")
# Both a real and a fake mailbox should produce the SAME class of reply
# so the attacker cannot tell them apart.
a = _send_cmd(s, b"VRFY known@example.com\r\n")
b = _send_cmd(s, b"VRFY does-not-exist@example.com\r\n")
assert a[:1] == b[:1], f"VRFY reply class differs: {a!r} vs {b!r}"
# And we should never confirm with 250 (which would mean "yes, exists").
assert not a.startswith(b"250"), a
finally:
s.close()
def test_expn_disabled():
s = _raw_session()
try:
_send_cmd(s, b"EHLO example.com\r\n")
resp = _send_cmd(s, b"EXPN postmaster\r\n")
assert resp[:3] in (b"502", b"500"), resp
finally:
s.close()
# ---------------------------------------------------------------------------
# 3. SMTP smuggling — CVE-2023-51764 family.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"smuggle_bytes",
[
# bare LF
b"\n.\r\n",
# bare CR
b"\r.\r\n",
# bare LF + bare LF EOD
b"\n.\n",
],
ids=["LF-dot-CRLF", "CR-dot-CRLF", "LF-dot-LF"],
)
def test_smtp_smuggling_does_not_split_messages(mock_api_server, smuggle_bytes):
"""A smuggling EOD variant must NOT split the envelope into two messages.
The MDA must see at most ONE delivery, and the "smuggled" MAIL FROM/RCPT
TO must appear as text inside that single message body — never as a
separately-delivered envelope to an attacker-chosen recipient.
"""
mock_api_server.add_mailbox("victim@example.com")
# Register the smuggled recipient too: otherwise an actual split would be
# rejected at RCPT by the MDA (mailbox not found) and the test would
# silently pass without proving smuggling failed.
mock_api_server.add_mailbox("smuggled@example.com")
s = _raw_session()
try:
_send_cmd(s, b"EHLO attacker.example\r\n")
_send_cmd(s, b"MAIL FROM:<outer@example.com>\r\n")
_send_cmd(s, b"RCPT TO:<victim@example.com>\r\n")
_send_cmd(s, b"DATA\r\n")
payload = (
b"Subject: outer\r\n"
b"\r\n"
b"outer body" + smuggle_bytes + b"MAIL FROM:<attacker@evil.example>\r\n"
b"RCPT TO:<smuggled@example.com>\r\n"
b"DATA\r\n"
b"Subject: smuggled\r\n"
b"\r\n"
b"smuggled body\r\n.\r\n"
)
s.sendall(payload)
# Final reply ends one or more responses; read until socket idle.
s.settimeout(3)
resp = b""
try:
while True:
chunk = s.recv(4096)
if not chunk:
break
resp += chunk
if b"\r\n" in chunk and resp.rstrip().endswith((b"OK", b"delivery", b"later")):
break
except socket.timeout:
pass
try:
_send_cmd(s, b"QUIT\r\n")
except OSError:
pass
finally:
s.close()
# The MDA must NOT have seen a second envelope with `smuggled@example.com`.
rcpts_seen = [
addr
for em in mock_api_server.received_emails
for addr in em["metadata"]["original_recipients"]
]
assert "smuggled@example.com" not in rcpts_seen, (
f"SMTP smuggling succeeded! Recipients seen: {rcpts_seen}"
)
# ---------------------------------------------------------------------------
# 4. NUL bytes in DATA must be rejected.
# ---------------------------------------------------------------------------
def test_nul_byte_in_body_rejected(mock_api_server, mta_impl):
if mta_impl == "postfix":
pytest.skip("Postfix accepts and silently normalizes NUL bytes; pymta is stricter")
mock_api_server.add_mailbox("test@example.com")
s = _raw_session()
try:
_send_cmd(s, b"EHLO example.com\r\n")
_send_cmd(s, b"MAIL FROM:<a@example.com>\r\n")
_send_cmd(s, b"RCPT TO:<test@example.com>\r\n")
_send_cmd(s, b"DATA\r\n")
s.sendall(b"Subject: NUL test\r\n\r\nhello\x00world\r\n.\r\n")
resp = _read_reply(s)
assert resp[:1] in (b"4", b"5"), resp
finally:
s.close()
# ---------------------------------------------------------------------------
# 5. Control-character / CRLF injection in MAIL FROM and RCPT TO.
# ---------------------------------------------------------------------------
def test_tab_in_address_rejected(mta_impl):
s = _raw_session()
try:
_send_cmd(s, b"EHLO example.com\r\n")
# TAB inside the address is a header-unfolding vector.
resp = _send_cmd(s, b"MAIL FROM:<bad\taddr@example.com>\r\n")
assert resp[:1] in (b"4", b"5"), resp
finally:
s.close()
# ---------------------------------------------------------------------------
# 6. Overlong local-parts / domains.
# ---------------------------------------------------------------------------
def test_overlong_local_part_rejected(mta_impl):
s = _raw_session()
try:
_send_cmd(s, b"EHLO example.com\r\n")
_send_cmd(s, b"MAIL FROM:<sender@example.com>\r\n")
long_local = b"a" * 200
resp = _send_cmd(s, b"RCPT TO:<" + long_local + b"@example.com>\r\n")
# 4xx (Postfix milter tempfail path) or 5xx (pymta strict reject) both
# satisfy the security requirement: the address must not be delivered.
assert resp[:1] in (b"4", b"5"), resp
finally:
s.close()
# ---------------------------------------------------------------------------
# 7. Pre-DATA SIZE oversize announcement must be rejected at MAIL FROM time.
# ---------------------------------------------------------------------------
def test_size_overlimit_rejected(mock_api_server, smtp_client):
s = _raw_session()
try:
_send_cmd(s, b"EHLO example.com\r\n")
# 1 GB announced — well above MAX_INCOMING_EMAIL_SIZE (30 MB).
resp = _send_cmd(s, b"MAIL FROM:<a@example.com> SIZE=1000000000\r\n")
assert resp[:3] in (b"552", b"452", b"550"), resp
finally:
s.close()
# ---------------------------------------------------------------------------
# 8. RSET resets the envelope state.
# ---------------------------------------------------------------------------
def test_rset_clears_envelope(mock_api_server, smtp_client):
mock_api_server.add_mailbox("test@example.com")
smtp_client.helo("example.com")
smtp_client.mail("a@example.com")
smtp_client.rcpt("test@example.com")
smtp_client.rset()
# After RSET, DATA without MAIL/RCPT must be refused.
code, _ = smtp_client.docmd("DATA")
assert code // 100 == 5, code
# ---------------------------------------------------------------------------
# 9. Hard-error limit / unknown-command flood eventually disconnects.
# ---------------------------------------------------------------------------
def test_unknown_command_flood_does_not_hang(mta_impl):
s = _raw_session(timeout=10)
try:
for i in range(200):
try:
s.sendall(f"GARBAGE{i}\r\n".encode())
_read_reply(s)
except OSError:
# Server closed the connection — that is the expected defense.
return
pytest.fail("server accepted 200 unknown commands without disconnecting")
finally:
s.close()
# ---------------------------------------------------------------------------
# 10. Line-length limit enforced.
# ---------------------------------------------------------------------------
def test_overlong_command_line_rejected():
s = _raw_session()
try:
s.sendall(b"A" * 5000 + b"\r\n")
resp = _read_reply(s)
# Either the server returned a 4xx/5xx error, or it closed the socket
# without replying. Both are acceptable defences against a flooded
# parser; silently accepting the line is not.
assert not resp or resp[:1] in (b"4", b"5"), resp
finally:
s.close()
# ---------------------------------------------------------------------------
# 11. Reverse-path / null-sender accepted (bounces).
# ---------------------------------------------------------------------------
def test_null_sender_accepted(mock_api_server, smtp_client):
mock_api_server.add_mailbox("test@example.com")
# smtplib doesn't directly support empty MAIL FROM; use docmd.
code, _ = smtp_client.helo("example.com")
assert code == 250
code, _ = smtp_client.docmd("MAIL FROM:<>")
assert code == 250, code
code, _ = smtp_client.docmd("RCPT TO:<test@example.com>")
assert code == 250, code
def test_null_recipient_rejected(smtp_client):
smtp_client.helo("example.com")
smtp_client.docmd("MAIL FROM:<sender@example.com>")
code, _ = smtp_client.docmd("RCPT TO:<>")
assert code // 100 == 5, code
# ---------------------------------------------------------------------------
# 12. Missing EHLO/HELO before MAIL → 503.
# ---------------------------------------------------------------------------
def test_mail_without_helo_rejected(mta_impl):
# Postfix in our config has `smtpd_helo_required = no` (the Postfix
# default) so it accepts MAIL FROM without a prior HELO. pymta is strict
# by default. This is a documented behaviour gap, not a security bug —
# the MDA will reject malformed envelopes either way.
if mta_impl == "postfix":
pytest.skip("Postfix accepts MAIL FROM without HELO by default")
s = _raw_session()
try:
resp = _send_cmd(s, b"MAIL FROM:<a@b.com>\r\n")
assert resp[:3] in (b"503", b"550"), resp
finally:
s.close()
+4 -21
View File
@@ -74,27 +74,10 @@ def test_partial_writes():
assert response.startswith("250") assert response.startswith("250")
@pytest.mark.skip(reason="TODO review") # Pipelining is deliberately NOT advertised in EHLO (see handle_EHLO) and
def test_pipelining_support(): # command_call_limit enforces strict per-verb counts. A test that pipelines
"""Test SMTP command pipelining support""" # multiple commands at once would assert behaviour we *avoid* — leaving the
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: # placeholder skipped would only rot, so we don't keep one.
s.connect((MTA_HOST, 25))
s.settimeout(2)
s.recv(1024) # Greeting
# Send multiple commands at once
pipeline = (
b"HELO example.com\r\nMAIL FROM:<sender@example.com>\r\nRCPT TO:<test@example.com>\r\n"
)
s.send(pipeline)
# Should get multiple responses
responses = []
for _ in range(3):
response = s.recv(1024).decode()
responses.append(response)
assert all(r.startswith("250") for r in responses)
@pytest.mark.skip(reason="Not supported for now") @pytest.mark.skip(reason="Not supported for now")
+93 -3
View File
@@ -2,6 +2,19 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.14.4, <4.0" requires-python = ">=3.14.4, <4.0"
[[package]]
name = "aiosmtpd"
version = "1.4.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "atpublic" },
{ name = "attrs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/ca/b2b7cc880403ef24be77383edaadfcf0098f5d7b9ddbf3e2c17ef0a6af0d/aiosmtpd-1.4.6.tar.gz", hash = "sha256:5a811826e1a5a06c25ebc3e6c4a704613eb9a1bcf6b78428fbe865f4f6c9a4b8", size = 152775, upload-time = "2024-05-18T11:37:50.029Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/39/d401756df60a8344848477d54fdf4ce0f50531f6149f3b8eaae9c06ae3dc/aiosmtpd-1.4.6-py3-none-any.whl", hash = "sha256:72c99179ba5aa9ae0abbda6994668239b64a5ce054471955fe75f581d2592475", size = 154263, upload-time = "2024-05-18T11:37:47.877Z" },
]
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
@@ -32,6 +45,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" },
] ]
[[package]]
name = "atpublic"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/05/e2e131a0debaf0f01b8a1b586f5f11713f6affc3e711b406f15f11eafc92/atpublic-7.0.0.tar.gz", hash = "sha256:466ef10d0c8bbd14fd02a5fbd5a8b6af6a846373d91106d3a07c16d72d96b63e", size = 17801, upload-time = "2025-11-29T05:56:45.45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/c0/271f3e1e3502a8decb8ee5c680dbed2d8dc2cd504f5e20f7ed491d5f37e1/atpublic-7.0.0-py3-none-any.whl", hash = "sha256:6702bd9e7245eb4e8220a3e222afcef7f87412154732271ee7deee4433b72b4b", size = 6421, upload-time = "2025-11-29T05:56:44.604Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.1.4" version = "2026.1.4"
@@ -158,6 +189,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
] ]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.11"
@@ -221,6 +280,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "prometheus-client"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.12.5"
@@ -323,6 +391,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
] ]
[[package]]
name = "pytest-asyncio"
version = "0.24.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855, upload-time = "2024-08-22T08:03:18.145Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024, upload-time = "2024-08-22T08:03:15.536Z" },
]
[[package]] [[package]]
name = "pytest-cov" name = "pytest-cov"
version = "6.0.0" version = "6.0.0"
@@ -381,8 +461,10 @@ name = "st-messages-mta-in"
version = "0.8.0" version = "0.8.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosmtpd" },
{ name = "httpx" },
{ name = "prometheus-client" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "pymilter" },
{ name = "requests" }, { name = "requests" },
] ]
@@ -391,26 +473,34 @@ dev = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "pylint" }, { name = "pylint" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
{ name = "types-requests" }, { name = "types-requests" },
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
postfix = [
{ name = "pymilter" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiosmtpd", specifier = "==1.4.6" },
{ name = "fastapi", marker = "extra == 'dev'", specifier = "==0.115.12" }, { name = "fastapi", marker = "extra == 'dev'", specifier = "==0.115.12" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "prometheus-client", specifier = "==0.24.1" },
{ name = "pyjwt", specifier = "==2.10.1" }, { name = "pyjwt", specifier = "==2.10.1" },
{ name = "pylint", marker = "extra == 'dev'", specifier = "==3.3.4" }, { name = "pylint", marker = "extra == 'dev'", specifier = "==3.3.4" },
{ name = "pymilter", specifier = "==1.0.5" }, { name = "pymilter", marker = "extra == 'postfix'", specifier = "==1.0.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.5" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==0.24.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = "==6.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = "==6.0.0" },
{ name = "requests", specifier = "==2.32.3" }, { name = "requests", specifier = "==2.32.3" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.9.3" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.9.3" },
{ name = "types-requests", marker = "extra == 'dev'", specifier = "==2.32.0.20241016" }, { name = "types-requests", marker = "extra == 'dev'", specifier = "==2.32.0.20241016" },
{ name = "uvicorn", marker = "extra == 'dev'", specifier = "==0.34.1" }, { name = "uvicorn", marker = "extra == 'dev'", specifier = "==0.34.1" },
] ]
provides-extras = ["dev"] provides-extras = ["postfix", "dev"]
[[package]] [[package]]
name = "starlette" name = "starlette"