A production-ready Go service that accepts SMTP messages and relays them to Resend via HTTPS API. Built with clean architecture principles (DDD + SOLID) for maintainability and scalability.
- DDD + SOLID: clear domain, application, and adapter layers
- SMTP in, Resend out: uses
emersion/go-smtpand Resend REST API - Advanced MIME parsing: supports multipart messages, HTML/text bodies, attachments
- HTTP health endpoint:
GET /healthon a second port for Railway healthchecks - Closed by default: SMTP AUTH (PLAIN) required; optional sender-domain allowlist
- Resilient delivery: retries Resend
429/5xxwith exponential backoff + jitter, honoringRetry-After - Correct SMTP semantics: transient upstream failures → SMTP
4xx(client retries), permanent →5xx - Graceful shutdown: concurrent SMTP + HTTP listeners drained on
SIGTERM - Structured logging: JSON in production; logs message-id, from, recipient domains, Resend id, latency, outcome — never the body or full recipient addresses
- Config via ENV: fail-fast validation at startup (no panics)
- Tests and Benchmarks: unit tests and micro-benchmark for the send path
- Docker & Railway: ready-to-deploy container and
railway.json
- ✅ Plain text emails
- ✅ HTML emails
- ✅ Multipart emails (text + HTML)
- ✅ Attachments (inline and regular)
- ✅ CC, BCC, and Reply-To headers
- ✅ Base64 and quoted-printable content transfer encoding
- ✅ Custom headers
export RESEND_API_KEY=your_resend_key
export SMTP_USER=user
export SMTP_PASS=pass
export SMTP_LISTEN_ADDR=:2525
export HEALTH_ADDR=:8080
go run ./cmd/gateway
# health check
curl -f http://localhost:8080/health # {"status":"ok"}
# send an email via SMTP to localhost:2525 (AUTH required)
# swaks --server localhost:2525 --auth PLAIN --auth-user user --auth-password pass \
# --from you@example.com --to them@example.com --header "Subject: Test" --body "Hello"To run without AUTH (private-network-only), set
SMTP_REQUIRE_AUTH=false.
The service validates its configuration at startup and fails fast with a clear message and non-zero exit (never a panic) if something required is missing.
RESEND_API_KEY(required): API key for Resend.SMTP_USER/SMTP_PASS(required when AUTH is enabled — the default): the credentials clients present via SMTP AUTH PLAIN. Startup fails if AUTH is required but these are unset.
SMTP_LISTEN_ADDR(default:2525): listen address for the SMTP server.- Example:
:2525,0.0.0.0:2525,localhost:2525, or a bare port2525.
- Example:
HEALTH_ADDR(default:8080): listen address for the HTTP health server.PORT: if set (Railway), overridesHEALTH_ADDRas":${PORT}".- Railway injects
PORTfor the public HTTP port it healthchecks. The SMTP listener is unaffected and stays onSMTP_LISTEN_ADDR(reached over the private network). ⚠️ Behavior change: previouslyPORToverrode the SMTP port. It now drives the HTTP health port sohealthcheckPath=/healthworks on Railway.
- Railway injects
SEND_TIMEOUT_SECONDS(default15): overall timeout for a single delivery, including all retries.SEND_MAX_RETRIES(default3): max retries (in addition to the first attempt) for transient Resend failures (429,5xx, network/timeout).SMTP_REQUIRE_AUTH(defaulttrue): require SMTP AUTH beforeMAIL FROM. Set tofalseonly when the listener is bound to a trusted private network (thenSMTP_USER/SMTP_PASSare not required).ALLOWED_FROM_DOMAINS(default unset): comma-separated allowlist of envelopeMAIL FROMdomains (e.g.example.com,mail.example.com). When set, senders from other domains are rejected with SMTP550. Unset allows any domain.LOG_LEVEL(defaultINFO): logging verbosity.- Possible values:
DEBUG,INFO,WARN,ERROR.
- Possible values:
GET /health returns 200 {"status":"ok"} when the process is up and
RESEND_API_KEY is present, otherwise 503 {"status":"unavailable"}.
# From another host/container:
curl -f http://<host>:8080/health
# Locally, using the built-in subcommand (also used as the Docker HEALTHCHECK):
./bin/resend-railway-gateway healthcheck # exit 0 = healthy, non-zero = notcmd/gateway # main: concurrent SMTP + HTTP health, graceful shutdown
internal/domain # core model, ports, typed send errors
internal/app # orchestration service, structured logging
internal/adapters/smtp # SMTP server: AUTH, allowlist, MIME parsing
internal/adapters/resend # Resend client + retry/backoff transport
internal/adapters/health # HTTP /health server
internal/config # env config loader + startup validation
internal/logging # slog setup
- Go 1.25 or later
- A Resend API key (get one here)
- Clone the repository
git clone https://github.com/igorrius/resend-railway-gateway.git
cd resend-railway-gateway- Set up environment
export RESEND_API_KEY=your_resend_key
export SMTP_LISTEN_ADDR=:2525- Run the gateway
make run
# or
go run ./cmd/gateway# Run all tests
make test
# Run benchmarks
make bench
# Run with coverage
go test -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -html=coverage.outmake build- Build the binarymake test- Run tests with race detectionmake bench- Run benchmarksmake lint- Run golangci-lintmake run- Run the gateway locallymake clean- Remove build artifacts
You can test the gateway using a variety of SMTP clients:
Using swaks:
swaks --server localhost:2525 \
--auth PLAIN --auth-user "$SMTP_USER" --auth-password "$SMTP_PASS" \
--from sender@example.com \
--to recipient@example.com \
--header "Subject: Test Email" \
--body "Hello from the SMTP gateway!"Using curl (for raw SMTP):
curl smtp://localhost:2525
# Then manually type SMTP commandsUsing Python:
import smtplib
from email.message import EmailMessage
msg = EmailMessage()
msg['From'] = 'sender@example.com'
msg['To'] = 'recipient@example.com'
msg['Subject'] = 'Test Email'
msg.set_content('Hello from Python!')
smtp = smtplib.SMTP('localhost', 2525)
smtp.login('user', 'pass') # SMTP_USER / SMTP_PASS
smtp.send_message(msg)
smtp.quit()- One-click deploy: Click the Railway button above
- Manual deploy:
# Install Railway CLI
npm i -g @railway/cli
# Login and initialize
railway login
railway init
# Required variables
railway variables set RESEND_API_KEY=your_key
railway variables set SMTP_USER=your_smtp_user
railway variables set SMTP_PASS=your_strong_password
# Optional: restrict senders
railway variables set ALLOWED_FROM_DOMAINS=example.com
# Deploy
railway uprailway.json sets healthcheckPath=/health. Railway injects PORT, which the
gateway uses for its HTTP health server; Railway polls /health on that port to
gate deploys and restarts.
The SMTP listener is meant to be reached only over Railway's private network, not exposed publicly. Point your calling service (Vaultwarden, an app's mailer, etc.) at the gateway's internal hostname and SMTP port:
SMTP host: <gateway-service-name>.railway.internal
SMTP port: 2525 # SMTP_LISTEN_ADDR (not the public $PORT)
SMTP user: $SMTP_USER
SMTP pass: $SMTP_PASS
Encryption: none/STARTTLS-off (AUTH PLAIN is carried over the trusted private network)
Because the private network is trusted, AUTH PLAIN is transmitted without TLS
(AllowInsecureAuth). Do not publicly expose the SMTP port. If you must,
put it behind TLS/a tunnel and keep AUTH enabled. Only set
SMTP_REQUIRE_AUTH=false when you are certain the port is private-network-only.
Build the image:
docker build -t resend-railway-gateway .Run locally:
docker run -p 2525:2525 \
-e RESEND_API_KEY=your_key \
-e SMTP_LISTEN_ADDR=:2525 \
resend-railway-gatewayProduction run:
docker run -d \
--name smtp-gateway \
--restart unless-stopped \
-p 2525:2525 \
-e RESEND_API_KEY=your_key \
resend-railway-gatewayRequired:
RESEND_API_KEY- Your Resend API keySMTP_USER/SMTP_PASS- SMTP AUTH credentials (required unlessSMTP_REQUIRE_AUTH=false)
Optional:
SMTP_LISTEN_ADDR- SMTP listen address (default::2525)HEALTH_ADDR- HTTP health address (default::8080, overridden byPORT)SEND_TIMEOUT_SECONDS- Overall send timeout incl. retries (default:15)SEND_MAX_RETRIES- Transient-failure retries (default:3)SMTP_REQUIRE_AUTH- Require AUTH (default:true)ALLOWED_FROM_DOMAINS- Comma-separated sender-domain allowlistLOG_LEVEL- Control logging verbosity
Port Configuration:
- Railway automatically provides the
PORTenvironment variable, which the gateway uses for its HTTP health server (0.0.0.0:${PORT}). - The SMTP listener stays on
SMTP_LISTEN_ADDR(default:2525) and is reached over the private network — see "Connecting your service over the private network" above.
The service follows a clean architecture with clear separation of concerns:
┌─────────────────────────────────────────────────┐
│ SMTP Clients │
└────────────────┬────────────────────────────────┘
│
┌────────────────▼────────────────────────────────┐
│ SMTP Adapter (go-smtp) │
│ - Accepts SMTP connections │
│ - Parses MIME messages │
│ - Converts to domain model │
└────────────────┬────────────────────────────────┘
│
┌────────────────▼────────────────────────────────┐
│ Application Service │
│ - Orchestrates business logic │
│ - Handles timeouts │
│ - Logs operations │
└────────────────┬────────────────────────────────┘
│
┌────────────────▼────────────────────────────────┐
│ Domain Model (Email, Ports) │
│ - Validates data │
│ - Business rules │
└────────────────┬────────────────────────────────┘
│
┌────────────────▼────────────────────────────────┐
│ Resend Adapter │
│ - Converts to Resend API │
│ - Sends via HTTPS │
└────────────────┬────────────────────────────────┘
│
┌────────────────▼────────────────────────────────┐
│ Resend API │
└─────────────────────────────────────────────────┘
- Domain Layer (
internal/domain): Core business entities and interfaces - Application Layer (
internal/app): Orchestrates use cases - Adapter Layer (
internal/adapters): External integrations (SMTP, Resend) - Config Layer (
internal/config): Environment-based configuration - Logging Layer (
internal/logging): Structured logging
-
Check logs: The gateway logs all operations at INFO level
LOG_LEVEL=DEBUG make run
-
Verify Resend API key: Ensure
RESEND_API_KEYis set correctlyecho $RESEND_API_KEY
-
Test SMTP connection:
telnet localhost 2525 # or nc localhost 2525 -
Check Railway logs: View deployment logs in Railway dashboard
If emails are timing out, increase the timeout:
export SEND_TIMEOUT_SECONDS=30If running in a container, ensure the port is exposed:
docker run -p 2525:2525 ...- ✅ Closed relay by default: SMTP AUTH (PLAIN) is required; unauthenticated
MAIL FROMis rejected with502. Startup fails if credentials are missing. - ✅ Sender allowlist: set
ALLOWED_FROM_DOMAINSto reject spoofed sender domains atMAIL FROM(550). - ✅ PII-aware logging: logs record recipient domains only — never full recipient addresses or the message body.
- ✅ Graceful shutdown prevents message loss during restarts.
⚠️ Keep the SMTP port on the private network. AUTH PLAIN is sent without TLS there because the network is trusted; do not expose it publicly without TLS/a tunnel.⚠️ SMTP_REQUIRE_AUTH=falsedisables AUTH — use only for private-network-only binds.⚠️ Consider additional rate limiting for high-volume production use.
- Transient Resend failures (
429,5xx, network/timeout) are retried up toSEND_MAX_RETRIEStimes with exponential backoff + full jitter, honoring theRetry-Afterheader. - If retries are exhausted on a transient failure, the client receives an SMTP
4xx(temporary) so it can retry later. Permanent failures (validation, auth) return SMTP5xx. The whole operation is bounded bySEND_TIMEOUT_SECONDS.
- Handles multiple concurrent SMTP connections
- Efficient MIME parsing without full message buffering
- Configurable timeout for Resend API calls
- Micro-benchmark: ~10k messages/second on modern hardware
Contributions are welcome! Please ensure:
- All tests pass:
make test - Code is formatted:
gofmt -s -w . - No linter errors:
make lint
Apache License 2.0 - see LICENSE file for details