A modern SMTP honeypot designed to capture and log email-based attacks with database integration.
Mailoney is a low-interaction SMTP honeypot that simulates a vulnerable mail server to detect and log unauthorized access attempts, credential harvesting, and other SMTP-based attacks. This version (2.1.0) is a complete rewrite with modern Python packaging practices and database logging.
- 📧 Simulates an SMTP server accepting connections on port 25
- 🔐 Captures authentication attempts and credentials
- 💾 Stores all session data in a database (PostgreSQL recommended)
- 🐳 Containerized for easy deployment via Docker
- 🛠️ Modern, maintainable Python code base
- 📊 Structured data for easy analysis and integration
Pull and run the container with a single command:
docker run -p 25:25 ghcr.io/phin3has/mailoney:latestThe most convenient way to run Mailoney with proper database persistence:
- Create a
docker-compose.ymlfile:
version: '3.8'
services:
mailoney:
image: ghcr.io/phin3has/mailoney:latest
restart: unless-stopped
ports:
- "25:25"
environment:
- MAILONEY_BIND_IP=0.0.0.0
- MAILONEY_BIND_PORT=25
- MAILONEY_SERVER_NAME=mail.example.com
- MAILONEY_LOG_LEVEL=INFO
- MAILONEY_DB_URL=postgresql://postgres:postgres@db:5432/mailoney
depends_on:
- db
db:
image: postgres:15
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=mailoney
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:- Start the services:
docker-compose up -d- View logs:
docker-compose logs -f mailoneyFor development or customization:
# Clone the repository
git clone https://github.com/phin3has/mailoney.git
cd mailoney
# Create a virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install the package in development mode
pip install -e .
# Run Mailoney
python main.py| Variable | Description | Default |
|---|---|---|
MAILONEY_BIND_IP |
IP address to bind to | 0.0.0.0 |
MAILONEY_BIND_PORT |
Port to listen on | 25 |
MAILONEY_SERVER_NAME |
SMTP server name | mail.example.com |
MAILONEY_CONN_TIMEOUT |
Per-connection inactivity timeout (seconds). A client that sends nothing for this long is dropped, so slow-loris connections cannot pin handler threads. 0 disables it. |
30 |
MAILONEY_DB_URL |
Database connection URL. Set to an empty string (MAILONEY_DB_URL=) to disable the database and run in event-logging-only mode. |
sqlite:///mailoney.db |
MAILONEY_MAIL_DIR |
When set, captured SMTP message bodies are written under this directory as <YYYY-MM-DD>/<src-ip>/<session>.eml and the session log records the relative path. Unset = bodies are discarded after their metadata (size, truncated flag) is recorded. Operators opt in to body retention. |
(unset) |
MAILONEY_TLS_CERT |
Path to a PEM cert/chain. Both MAILONEY_TLS_CERT and MAILONEY_TLS_KEY must be set to enable STARTTLS. |
(unset) |
MAILONEY_TLS_KEY |
Path to the PEM private key matching MAILONEY_TLS_CERT. |
(unset) |
MAILONEY_LOG_LEVEL |
Logging level | INFO |
MAILONEY_LOG_JSON |
When true, every log line on stdout — both honeypot events (session start/end, captured credentials) and operational records (mailoney.core, mailoney.mail_storage, …) — is emitted as JSON Lines. Default emits human-readable text for both. |
false |
MAILONEY_METRICS_PORT |
Port for the Prometheus /metrics endpoint. Unset disables the endpoint. |
(unset) |
MAILONEY_METRICS_BIND |
Bind address for the metrics endpoint. Loopback by default; set 0.0.0.0 or :: only to scrape from another host/container, and keep that port off public interfaces (see Prometheus Metrics). |
127.0.0.1 |
Mailoney can run as a pure event emitter — useful when you ship logs to a SIEM, ELK stack, Loki, etc. and don't want to operate a SQL database just for honeypot capture.
# DB-less mode, JSON log output suitable for log shippers
docker run -p 25:25 \
-e MAILONEY_DB_URL= \
-e MAILONEY_LOG_JSON=true \
ghcr.io/phin3has/mailoney:latestWhen the database is disabled:
- No tables are created, no migrations are run, no DB driver is required at runtime.
- Per-session events (
session_started,credential_captured,session_ended) are emitted to themailoney.eventslogger. These are emitted whether or not the database is enabled; the database is simply one more sink.
session_ended carries a per-session summary, not the transcript:
source/destination, duration_seconds, command_count, the commands
list (capped at 200 entries, with commands_truncated: true beyond that),
last_response_code, outcome (ok / error / timeout), and, when
present, credentials, mail (per-DATA size/truncation/path metadata),
mail_size, tls_version, tls_error and timed_out. Message bodies are
never inlined. Text mode drops the command and credential lists for
readability; JSON mode keeps them.
When the database is enabled, it keeps storing the full per-command
transcript in smtp_sessions.session_data exactly as before — the summary
does not replace it.
With MAILONEY_LOG_JSON=true, stdout is JSON Lines only; the startup
banner and [*] status lines go to stderr, so a shipper attached to stdout
never sees a non-JSON line.
When running directly:
python main.py --helpAvailable arguments:
-i,--ip: IP address to bind to-p,--port: Port to listen on-s,--server-name: Server name to display in SMTP responses--conn-timeout: Per-connection inactivity timeout in seconds (0 disables it)-d,--db-url: Database URL. Pass an empty string to disable the database.--mail-dir: Directory under which captured message bodies are written--tls-cert: Path to a PEM cert/chain (enables STARTTLS together with --tls-key)--tls-key: Path to the PEM private key matching --tls-cert--log-level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)--log-json: Emit honeypot events as JSON Lines instead of human-readable text.--metrics-port: Port for the Prometheus/metricsendpoint (unset = disabled)--metrics-bind: Bind address for the metrics endpoint (default:127.0.0.1, loopback only)
Mailoney implements the SMTP DATA phase: when a client sends DATA, the
server responds with 354 End data with <CR><LF>.<CR><LF>, reads the
message body until the standard terminator (or 1 MiB, whichever comes
first), and replies 250 Ok.
Storage is opt-in:
- Default (
MAILONEY_MAIL_DIRunset): the body is discarded after the session log records its metadata (size,truncated). The operator gets a record that a body was received but no body content is retained. MAILONEY_MAIL_DIR=/path: the body is written to<path>/<YYYY-MM-DD>/<src-ip>/<session-uuid>.eml(mode0640, directories0750) and the session log records the relative path. This is the only way to retain body content; the JSON log stream never carries body bytes inline regardless ofMAILONEY_LOG_JSON.
Source IPs land in directory names with their colons intact (e.g.
2001:db8::1).
Mailoney can use various SQL databases:
SQLite (simplest, for testing):
sqlite:///mailoney.db
PostgreSQL (recommended for production):
postgresql://username:password@hostname:port/database
MySQL/MariaDB:
mysql+pymysql://username:password@hostname:port/database
Mailoney supports the SMTP STARTTLS extension when both
MAILONEY_TLS_CERT and MAILONEY_TLS_KEY are set. Any cert/key pair
works — the server presents the cert and never verifies anything, so a
self-signed cert is perfectly adequate for a honeypot.
When unconfigured, EHLO does not advertise STARTTLS, and an explicit
STARTTLS command from a client gets 454 4.7.0 TLS not available.
The container image runs as the unprivileged mailoney user, and
private keys are root-owned and mode 0600/0640 almost everywhere
they are generated. Bind-mounting /etc/letsencrypt or /etc/ssl
straight into the container therefore does not work on its own —
the process cannot read the key.
Mailoney checks this at startup and exits with status 2 and an explicit message rather than failing later at handshake time:
[!] STARTTLS: TLS key is not readable: /etc/letsencrypt/live/mx.example.com/privkey.pem
Two ways to resolve it, in order of preference:
- Copy the pair to a location the container user can read (below).
- Pin the container to a known UID with
user:in compose, andchownthe copied files to it.
Running the container as root also works but gives up the image's privilege separation, which is a poor trade for a service that deliberately accepts hostile input on port 25.
Generate a throwaway pair:
mkdir -p ./tls
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout ./tls/privkey.pem -out ./tls/fullchain.pem \
-subj "/CN=mx.example.com"
chmod 640 ./tls/privkey.pemOn Debian/Ubuntu the ssl-cert package's snakeoil pair works too, but
it lives in /etc/ssl/private (mode 0710, group ssl-cert) and the
key is 0640 root:ssl-cert, so copy it out rather than mounting it:
mkdir -p ./tls
sudo install -m 0644 /etc/ssl/certs/ssl-cert-snakeoil.pem ./tls/fullchain.pem
sudo install -m 0640 /etc/ssl/private/ssl-cert-snakeoil.key ./tls/privkey.pem
sudo chown -R 1000:1000 ./tlsEither way, mount the directory and pin the user:
services:
mailoney:
image: ghcr.io/phin3has/mailoney:latest
user: "1000:1000"
ports:
- "25:25"
environment:
- MAILONEY_TLS_CERT=/tls/fullchain.pem
- MAILONEY_TLS_KEY=/tls/privkey.pem
volumes:
- ./tls:/tls:roCertbot writes privkey.pem as 0600 root:root, so the honeypot
container cannot read it from a bind mount. Use a deploy hook to
publish a readable copy on every renewal, then restart the container so
the new cert is picked up:
# /etc/letsencrypt/renewal-hooks/deploy/mailoney.sh (chmod +x)
#!/bin/sh
set -eu
DEST=/srv/mailoney/tls
install -d -m 0750 -o 1000 -g 1000 "$DEST"
install -m 0644 -o 1000 -g 1000 "$RENEWED_LINEAGE/fullchain.pem" "$DEST/fullchain.pem"
install -m 0640 -o 1000 -g 1000 "$RENEWED_LINEAGE/privkey.pem" "$DEST/privkey.pem"
docker compose -f /srv/mailoney/docker-compose.yml restart mailoney$RENEWED_LINEAGE is set by certbot to the live/<domain> directory
being renewed, so the hook needs no per-domain editing. It runs on
issuance and on every successful renewal.
services:
mailoney:
image: ghcr.io/phin3has/mailoney:latest
user: "1000:1000"
ports:
- "25:25"
environment:
- MAILONEY_TLS_CERT=/tls/fullchain.pem
- MAILONEY_TLS_KEY=/tls/privkey.pem
volumes:
- /srv/mailoney/tls:/tls:roUsing a real cert for a honeypot is a deliberate choice: it makes the listener look like production infrastructure, at the cost of tying a domain you control to it (and publishing the hostname in the Certificate Transparency logs). A self-signed cert leaks nothing but is a fingerprintable signal in itself. Neither is wrong — pick per deployment.
- The
EHLOresponse advertisesSTARTTLS. After a successful upgrade, the post-TLSEHLOdrops the line per RFC 3207 §4.2. - The handshake is pinned to TLS 1.2 minimum.
- A 10-second handshake timeout protects against half-open clients; the per-connection inactivity timeout is restored afterwards, so an upgraded connection is bounded exactly like a plaintext one.
- The upgrade is recorded in the session log as a
tls-upgradeentry carrying the negotiated version. - A repeat
STARTTLSon an encrypted connection gets503 5.5.1 STARTTLS already active. - Cert and key are loaded once at process start. Restart the
container after a renewal — hence the
restartin the deploy hook above.
TLS configuration is checked before the database is touched, so a cert problem never surfaces as a database error:
| Condition | Behaviour |
|---|---|
| Neither cert nor key set | STARTTLS disabled, no message (the default) |
| Only one of the two set | Warning on stderr; server starts, plaintext only |
| Path missing | exit 2, names the missing file |
| Path unreadable | exit 2, names the file and the likely cause |
| Cert/key mismatch, malformed PEM | exit 2, reports the OpenSSL error |
Mailoney can expose a /metrics endpoint for Prometheus scraping when
MAILONEY_METRICS_PORT is set (or --metrics-port is passed).
Keep this endpoint off the internet. The exposition is unauthenticated, is served on every path, and includes
mailoney_build_info— the software name and version. Anyone who can reach it learns in one request that the "mail server" is a honeypot. The endpoint therefore binds to loopback (127.0.0.1) by default, and Mailoney logs a warning at startup whenever it is bound anywhere else.
Running directly on the host, the default just works and Prometheus on the
same machine scrapes 127.0.0.1:9025:
MAILONEY_METRICS_PORT=9025 python main.pyIn Docker, port publishing forwards to the container's network interface,
not its loopback, so the container-side bind must be widened to 0.0.0.0
(or ::) and the exposure controlled on the Docker side instead. Two safe
patterns:
Scrape over an internal network, publish nothing. Prometheus and Mailoney share a compose network; only port 25 reaches the outside:
services:
mailoney:
image: ghcr.io/phin3has/mailoney:latest
ports:
- "25:25" # only the honeypot port is published
environment:
- MAILONEY_METRICS_PORT=9025
- MAILONEY_METRICS_BIND=0.0.0.0
networks: [monitoring]
prometheus:
image: prom/prometheus
networks: [monitoring] # scrapes http://mailoney:9025/metrics
networks:
monitoring:Publish to the host's loopback only. For a Prometheus that runs on the Docker host itself:
docker run -p 25:25 -p 127.0.0.1:9025:9025 \
-e MAILONEY_METRICS_PORT=9025 \
-e MAILONEY_METRICS_BIND=0.0.0.0 \
ghcr.io/phin3has/mailoney:latestNever use a bare -p 9025:9025 on an internet-facing host: that publishes
the fingerprint on every interface.
Exposed metrics:
| Metric | Type | Labels | Description |
|---|---|---|---|
mailoney_smtp_connections_total |
Counter | — | SMTP connections accepted. |
mailoney_smtp_sessions_total |
Counter | result (ok/error/timeout) |
SMTP sessions that ran to completion. timeout is an inactivity drop, reported separately so slow-loris pressure is visible. |
mailoney_smtp_credentials_captured_total |
Counter | — | AUTH PLAIN credentials captured. |
mailoney_smtp_commands_total |
Counter | command |
SMTP commands by verb (ehlo, helo, auth, starttls, mail, rcpt, data, quit, unknown). |
mailoney_smtp_active_sessions |
Gauge | — | Sessions currently in flight. |
mailoney_smtp_session_duration_seconds |
Histogram | — | Time from accept to close, per session. |
mailoney_smtp_banner_only_sessions_total |
Counter | — | Sessions where the client connected but never sent a command (port-scanner signal). |
mailoney_start_time_seconds |
Gauge | — | Unix timestamp at process start. Compute uptime in PromQL with time() - mailoney_start_time_seconds. |
mailoney_build_info |
Info | version |
Mailoney build/version info. |
MAILONEY_METRICS_BIND (or --metrics-bind) overrides the bind address.
Mailoney creates two main tables:
-
smtp_sessions: Stores information about each SMTP session- Session ID, timestamp, IP address, port, server name
- Full JSON log of the entire session
-
credentials: Stores captured authentication credentials- Credential ID, timestamp, session ID, auth string
# Install test dependencies
pip install pytest pytest-cov
# Run tests
pytest
# Run tests with coverage
pytest --cov=mailoney# Create a new migration
alembic revision --autogenerate -m "Description of changes"
# Apply migrations
alembic upgrade head# Install build tools
pip install build
# Build the package
python -m buildmailoney/
├── mailoney/ # Main package
│ ├── __init__.py # Package initialization
│ ├── core.py # Core server functionality
│ ├── db.py # Database handling
│ ├── config.py # Configuration management
│ └── migrations/ # Database migrations
├── tests/ # Test suite
├── main.py # Clean entry point
├── docker-compose.yml # Docker Compose configuration
├── Dockerfile # Docker configuration
├── pyproject.toml # Package configuration
└── ... other files
- Mailoney is a honeypot and should be deployed in a controlled environment
- Consider running with limited privileges
- Firewall appropriately to prevent misuse
- Regularly backup and analyze collected data
Mailoney stores all interaction data in the database. To integrate with SIEM or other security tools:
- Direct Database Integration: Connect your security tools to the PostgreSQL database
- Log Forwarding: Use a separate service to monitor the database and forward events
- API Development: Extend Mailoney to provide a REST API for data access
MIT License - See LICENSE file for details.
This project is a modernized rewrite of the original Mailoney by @phin3has.