SMTP in, ForwardEmail HTTPS API out.
Some hosts block outbound SMTP on every port, including providers' alternate ports, while HTTPS APIs such as https://api.forwardemail.net stay reachable. Plenty of software can only send mail over SMTP (Django's email backend, for one). This bridge sits in between: apps send to it over SMTP on the internal network, and it forwards each message through ForwardEmail's API.
The message is passed through as raw RFC822, so headers, multipart bodies and attachments arrive exactly as the sending app wrote them.
docker run -d --name mail-bridge \
-e FORWARDEMAIL_USERNAME=alerts@example.com \
-e FORWARDEMAIL_PASSWORD=<alias password> \
-e SMTP_USERNAME=app \
-e SMTP_PASSWORD=<what your apps will use> \
ghcr.io/ericdahl-dev/forwardemail-smtp-bridgeThen point an app at it, e.g. Django/GlitchTip:
EMAIL_URL=smtp://app:<SMTP_PASSWORD>@mail-bridge:2525
ForwardEmail only sends a message whose From address is the alias the credentials belong to. To let each app send as its own address without a domain catch-all password, give each app its own SMTP login mapped to its own alias in BRIDGE_ACCOUNTS, a JSON list:
docker run -d --name mail-bridge \
-e BRIDGE_ACCOUNTS='[
{"smtp_username": "glitchtip", "smtp_password": "<app password>",
"forwardemail_username": "alerts@example.com", "forwardemail_password": "<alias password>"},
{"smtp_username": "billing", "smtp_password": "<app password>",
"forwardemail_username": "billing@example.com", "forwardemail_password": "<alias password>"}
]' \
ghcr.io/ericdahl-dev/forwardemail-smtp-bridgeGlitchTip then logs in as glitchtip and sends as alerts@example.com; the billing app logs in as billing and sends as billing@example.com. All four keys are required in every entry.
The single-login variables still work, alone or alongside BRIDGE_ACCOUNTS (they become one more login), so an existing deployment can add logins without changing what it has.
| Variable | Required | Default | |
|---|---|---|---|
FORWARDEMAIL_USERNAME |
unless BRIDGE_ACCOUNTS is set |
An alias of your domain, e.g. alerts@example.com |
|
FORWARDEMAIL_PASSWORD |
unless BRIDGE_ACCOUNTS is set |
That alias's generated password | |
SMTP_USERNAME |
unless BRIDGE_ACCOUNTS is set |
What sending apps authenticate with | |
SMTP_PASSWORD |
unless BRIDGE_ACCOUNTS is set |
" | |
BRIDGE_ACCOUNTS |
no | JSON list of logins, each {"smtp_username", "smtp_password", "forwardemail_username", "forwardemail_password"}. See above |
|
SMTP_HOST |
no | 0.0.0.0 |
|
SMTP_PORT |
no | 2525 |
The four single-login variables go together: set all of them or none. The bridge refuses to start, with a message naming the problem, if they are partly set, if a BRIDGE_ACCOUNTS entry is missing a key or has an unknown one, or if two logins share an SMTP username. These messages never include the configured values.
python -m bridge.healthcheck connects to the bridge's own SMTP port (SMTP_PORT, default 2525) on loopback and sends NOOP. It exits 0 when the server answers and 1 otherwise. It never logs in or sends mail. The image declares it as its Docker HEALTHCHECK. On a platform with its own health checks, such as Coolify, use it as a command check: Coolify's HTTP checks can't test an SMTP server.
- Authentication is mandatory. An SMTP server that forwards to a real provider without auth is an open relay. Unauthenticated mail is refused, and so is a bad password (535). Passwords are compared in constant time.
- From must match the login's alias. The From header must be exactly one address, equal (ignoring case) to the
FORWARDEMAIL_USERNAME/forwardemail_usernameof the login that sent it. Anything else is refused with a permanent553 From address must be alerts@example.com for this login, instead of ForwardEmail's 403 turning into a 451 the app would retry forever. The envelope sender (MAIL FROM) is not checked; it is only the bounce address. - Failures are temporary (451), never silent. If ForwardEmail rejects the message or can't be reached, the sending app is told to retry rather than being told the mail was sent.
- Plaintext SMTP. Keep this on an internal network; don't publish the port. Credentials cross the wire reversibly.
- Errors are sanitized into a single SMTP line, and the alias password never appears in an error or log.
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python -r requirements.txt pytest pytest-asyncio
.venv/bin/python -m pytest -qMIT licensed.