generic-smtp-listener
A small persistent SMTP listener that accepts incoming mail and writes each complete message as a file in an inbox directory.
This project is intentionally permissive: it accepts any sender and recipient and does not perform spam filtering. It is intended for private capture/ingestion workflows, not as a full MTA replacement.
Behavior
- Listens on
SMTP_LISTEN_ADDR(default0.0.0.0:2525). - Supports basic SMTP commands:
HELO/EHLO,MAIL FROM,RCPT TO,DATA,RSET,NOOP, andQUIT. - Applies coarse global and per-envelope-sender rate limits.
- Applies a concurrent connection limit and an idle command/DATA timeout.
- Writes message data to a temporary directory first, then atomically renames the complete file into the inbox.
- Also writes a cleaned copy into
SMTP_CLEANED_INBOX_DIRfor LLM ingestion. - When
SMTP_RECIPIENT_DOMAINSis set, only messages addressed to those domains are stored, with one copy per matching local-part username folder. - When
SMTP_DISCARD_SENDER_DOMAINSis set, messages whose envelopeMAIL FROMorFromheader domain matches are accepted with250but never stored or webhook-notified, giving the sender no indication of the discard. - Can POST a local HTTP webhook after a message is stored, so an internal app can import or notify the recipient address.
- Keeps temporary files outside both inboxes by default, so rsync jobs can read inboxes without seeing partial messages.
- Prepends envelope metadata headers before the received DATA payload.
- Message filenames use
[YYYY]-[MM]-[DD]-[base64_url encoded sha1 content hash].eml, based on the full stored message content. - Cleaned copies strip
X-*,ARC-*, andDKIM*headers exceptX-ReceivedandX-SMTP*, keep plain-text bodies, drop HTML alternatives, and keep attachments with minimal MIME headers.
Configuration
Configuration is read from /etc/smtp-receiver/config when that file exists, or from the file named by SMTP_CONFIG_FILE. The file uses simple key=value lines; blank lines and # comments are ignored. Keys may be written either with or without the SMTP_ prefix, for example listen_addr=0.0.0.0:25 or SMTP_LISTEN_ADDR=0.0.0.0:25.
Environment variables with the same names are still supported and override file values, mainly for local development and container orchestration.
| Key / Variable | Default | Description |
|---|---|---|
SMTP_LISTEN_ADDR |
0.0.0.0:2525 |
TCP listen address. Use 0.0.0.0:25 when deployed with permission to bind port 25. |
SMTP_INBOX_DIR |
inbox |
Directory where complete .eml files are delivered. |
SMTP_CLEANED_INBOX_DIR |
sibling inbox-cleaned |
Directory where stripped LLM-ingestion .eml files are delivered. |
SMTP_TEMP_DIR |
sibling .smtp-receiver-tmp |
Temporary write directory used before atomic rename into the inboxes. |
SMTP_MAX_MESSAGE_BYTES |
26214400 |
Maximum SMTP DATA size. |
SMTP_GLOBAL_RATE_PER_MINUTE |
600 |
Global accepted MAIL FROM commands per rolling minute. Set 0 to disable. |
SMTP_SENDER_RATE_PER_MINUTE |
60 |
Per-sender accepted MAIL FROM commands per rolling minute. Set 0 to disable. |
SMTP_MAX_CONNECTIONS |
100 |
Maximum concurrent SMTP sessions. Set 0 to use Tokio's maximum semaphore permit count. |
SMTP_COMMAND_TIMEOUT_SECONDS |
300 |
Idle timeout for SMTP commands and DATA reads. |
SMTP_RECIPIENT_DOMAINS |
unset | Comma-separated recipient domains to route into username folders, for example kautschuk.com,undenheim.kautschuk.com. Matching uses envelope recipients plus To, Cc, and Bcc headers. If set and no recipient matches, the message is accepted but not stored. |
SMTP_DISCARD_SENDER_DOMAINS |
unset | Comma-separated sender domains to silently discard, for example no-reply.com. Matching uses the envelope MAIL FROM domain plus the RFC5322 From header domain (case-insensitive, exact host match). Matching messages are accepted with 250 but never stored or webhook-notified, giving the sender no indication of the discard. |
SMTP_RELAY_ROUTES |
unset | Comma-separated domain=host:port routes, for example kg1.app=10.4.2.121:2525. Envelope recipients in a routed domain are handed to that SMTP listener during the DATA phase (with a Received: line naming the client IP) instead of being stored; other recipients are stored as usual. If a hand-off fails the client gets 451 and retries, so nothing is queued locally. Sender discard rules apply before relaying. |
SMTP_AUTH_RESULTS_MODE |
off |
Offline Authentication-Results gate. Use require to accept but not store messages unless a trusted Authentication-Results header satisfies the configured result requirements. This makes no DNS or network requests and is only as trustworthy as the configured upstream path. |
SMTP_AUTH_RESULTS_TRUSTED_SERVERS |
unset | Comma-separated authserv-id values whose Authentication-Results headers may be trusted, for example mx.google.com. Required when SMTP_AUTH_RESULTS_MODE=require. |
SMTP_AUTH_RESULTS_REQUIRED |
unset | Comma-separated lowercase result fragments to require inside a trusted header, for example dkim=pass,dmarc=pass. Required when SMTP_AUTH_RESULTS_MODE=require. |
SMTP_AUTH_RESULTS_MATCH |
any |
Whether any or all configured required result fragments must appear in the trusted Authentication-Results header. |
SMTP_WEBHOOK_URL |
unset | Optional HTTP webhook URL called after successful storage. Only http:// is supported to avoid adding a TLS client dependency; keep it on trusted local/internal networks. The SMTP delivery remains accepted if the webhook fails. |
SMTP_WEBHOOK_TOKEN |
unset | Optional bearer token sent as Authorization: Bearer ... with webhook requests. |
SMTP_WEBHOOK_TIMEOUT_SECONDS |
5 |
Webhook request timeout. |
Example config file:
listen_addr=0.0.0.0:25
inbox_dir=/var/lib/smtp-receiver/dropoff
cleaned_inbox_dir=/var/lib/smtp-receiver/dropoff-cleaned
temp_dir=/var/lib/smtp-receiver/tmp
max_message_bytes=26214400
global_rate_per_minute=600
sender_rate_per_minute=60
max_connections=100
command_timeout_seconds=300
recipient_domains=kautschuk.com,undenheim.kautschuk.com
auth_results_mode=require
auth_results_trusted_servers=mx.google.com
auth_results_required=dkim=pass,dmarc=pass
auth_results_match=any
webhook_url=http://127.0.0.1:8080/new-mail
webhook_token=local-shared-secret
webhook_timeout_seconds=5
Local development
cargo fmt
cargo test
cargo clippy --all-targets -- -D warnings
SMTP_LISTEN_ADDR=127.0.0.1:2525 SMTP_INBOX_DIR=tmp/inbox cargo run
Example manual smoke test:
nc 127.0.0.1 2525
Then type:
EHLO local
MAIL FROM:<sender@example.test>
RCPT TO:<inbox@example.test>
DATA
Subject: hello
body
.
QUIT
Webhook payload
When enabled, the receiver sends one POST after a message has been atomically written. Payload example:
{
"event": "message_stored",
"sender": "<sender@example.test>",
"recipients": ["<udo@example.test>"],
"stored_paths": ["/var/lib/smtp-receiver/dropoff/udo/2026-06-20-....eml"]
}
Webhook failure is logged to stderr and does not roll back or reject SMTP delivery.
Security notes
- This listener is deliberately accepting. Put it behind appropriate network controls for your use case.
- Webhooks are disabled by default. If enabled, point them at a trusted local/internal endpoint; external webhook URLs can leak metadata and create an operational dependency.
- The cleaned inbox is for token reduction, not for security sanitization. Treat email contents and attachments as untrusted input.
- The server does not implement authentication, TLS, spam filtering, DKIM/SPF/DMARC validation, mailbox routing, or outbound mail delivery.