2026-10-06 14:49:54 +00:00
2026-10-06 14:49:54 +00:00
2026-05-20 14:45:39 +00:00
2026-05-20 15:21:37 +00:00
2026-05-20 15:21:37 +00:00
2026-10-06 14:49:54 +00:00

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 (default 0.0.0.0:2525).
  • Supports basic SMTP commands: HELO/EHLO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, and QUIT.
  • 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_DIR for LLM ingestion.
  • When SMTP_RECIPIENT_DOMAINS is set, only messages addressed to those domains are stored, with one copy per matching local-part username folder.
  • When SMTP_DISCARD_SENDER_DOMAINS is set, messages whose envelope MAIL FROM or From header domain matches are accepted with 250 but 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-*, and DKIM* headers except X-Received and X-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.
S
Description
No description provided
Readme
362 KiB
0 Stars 1 Watchers 0 Forks
Languages
Rust 100%