Reusable inbound SMTP receiver powered by Haraka. It accepts mail, stores each
message in a durable local spool, then POSTs a Mailgun-like /mime payload to a
configurable webhook.
- Listens for inbound SMTP delivery on
25/TCP. - Accepts every recipient by default.
- Optionally restricts accepted recipient domains with environment variables.
- Stores raw RFC822/MIME messages in a persistent spool before acknowledging SMTP.
- Retries webhook delivery in the background.
- Sends
application/x-www-form-urlencodedpayloads withbody-mime. - Supports opportunistic inbound STARTTLS when certificate files are mounted.
cp .env.example .env
docker compose up -dTo build the image locally from this repository instead:
docker compose -f docker-compose.build.yml up --build -dEdit .env before production use:
WEBHOOK_URL=https://webhook.example.com/inbound
WEBHOOK_SIGNING_KEY=change-me
ACCEPT_ALL_RECIPIENTS=trueOptional pre-webhook decision API:
WEBHOOK_DECISION_URL=https://policy.example.com/haraka/decision
WEBHOOK_DECISION_TOKEN=change-me
WEBHOOK_DECISION_PAYLOAD_MODE=minimalThe receiver POSTs application/x-www-form-urlencoded fields similar to
Mailgun's raw MIME route:
recipientsenderfromsubjectmessage-headerstimestamptokensignature, whenWEBHOOK_SIGNING_KEYis setbody-mime
The signature is an HMAC-SHA256 hex digest of timestamp + token, keyed by
WEBHOOK_SIGNING_KEY.
Default behavior accepts every RCPT TO delivered to this server:
ACCEPT_ALL_RECIPIENTS=trueTo restrict by domain:
ACCEPT_ALL_RECIPIENTS=false
ACCEPTED_DOMAINS=example.com,example.netWhen WEBHOOK_DECISION_URL is set, each message is checked before it is accepted
into the spool. The decision API can return allow, deny, or silent_deny.
Failures are treated as temporary SMTP failures so the sender can retry later.
WEBHOOK_DECISION_PAYLOAD_MODE controls how much message data is sent to that
API: minimal, summary, or full. See DecisionProtocol.md.
When WEBHOOK_DECISION_TOKEN is set, the decision request includes
Authorization: Bearer <token>.
When WEBHOOK_ROLE_URL is set, mail addressed to a role local-part is delivered
to that second webhook instead of WEBHOOK_URL:
WEBHOOK_ROLE_URL=https://webhook.example.com/role-inboundIf any recipient's local-part (the part before @, case-insensitive) is a
role address, the whole message is POSTed to the role webhook; otherwise it goes
to WEBHOOK_URL. The payload, signing key (WEBHOOK_SIGNING_KEY), and timeout
are identical for both webhooks — only the URL differs. When WEBHOOK_ROLE_URL
is unset, all mail goes to WEBHOOK_URL (unchanged behavior).
The default role list is:
abuse, admin, administrator, billing, contact, daemon, help, hostmaster, info,
legal, mail, mailerdaemon, marketing, noc, noreply, nostr, postmaster, privacy,
root, sales, security, spam, sysadmin, support, usenet, uucp, webmaster, www
Override it with ROLE_ADDRESSES (comma-separated). Setting this replaces
the default list rather than extending it:
ROLE_ADDRESSES=abuse,admin,postmaster,supportHaraka uses mounted certificate files for SMTP STARTTLS. Use certbot,
acme.sh, lego, your DNS provider, or your hosting platform to obtain and
renew certs.
Mount the certificate files read-only in docker-compose.yml, then set:
SMTP_TLS_CERT_PATH=/certs/fullchain.pem
SMTP_TLS_KEY_PATH=/certs/privkey.pemSTARTTLS is opportunistic by default, which keeps delivery compatible with standard MX traffic.
For each domain that should deliver mail here, create MX records pointing to the
host running this container. That host also needs an A and/or AAAA record and
public inbound 25/TCP.
Example:
example.com. MX 10 mail.example.com.
example.net. MX 10 mail.example.com.
mail.example.com. A 203.0.113.10
Spool directories live under SPOOL_DIR:
pending/: messages waiting for webhook deliveryprocessing/: messages currently being delivereddead/: permanent failures, including webhook406tmp/: temporary writes before atomic move intopending/
Useful settings:
SPOOL_DIR=/var/spool/haraka-webhook
WEBHOOK_TIMEOUT_MS=10000
RETRY_SCAN_INTERVAL_MS=5000
RETRY_INTERVAL_MS=30000
RETRY_MAX_INTERVAL_MS=3600000
MAX_RETRY_AGE_MS=0MAX_RETRY_AGE_MS=0 means retry forever, except 406, which is dead-lettered.
For disk-level secrecy, run the spool volume on encrypted storage.
npm testAfter installing dependencies, you can run Haraka locally:
WEBHOOK_URL=https://example.com/hook npm startUse swaks to send a test message:
swaks -s 127.0.0.1 -p 25 -f sender@example.com -t user@example.comMIT