Lightweight email verification API via SMTP RCPT TO probing. Written in Go, zero dependencies beyond stdlib.
HTTP API that verifies whether an email address exists by connecting directly to the recipient's MX server and performing an SMTP RCPT TO check. No email is ever sent - it only probes the SMTP envelope.
- Caller sends
POST /verifywith an email address - mailprobe resolves MX records for the domain via DNS
- Connects to the highest-priority MX server on port 25
- Performs SMTP handshake:
EHLO->MAIL FROM->RCPT TO->RSET->QUIT - Returns the result based on SMTP response code (
250= exists,550= doesn't exist)
docker pull ghcr.io/sonquer/mailprobe:latest
docker run -p 8080:8080 ghcr.io/sonquer/mailprobe:latestcd src
go build -o mailprobe ./cmd/mailprobe
./mailprobeIf API_KEYS is set, all requests to /verify and /verify/batch must include a valid X-API-Key header. The /health and /version endpoints are always open.
curl -X POST http://localhost:8080/verify \
-H "Content-Type: application/json" \
-H "X-API-Key: your-secret-key" \
-d '{"email": "user@example.com"}'If API_KEYS is not set, the API is open with no authentication required.
POST /verify
Content-Type: application/json
Request:
{
"email": "jan.kowalski@firma.pl"
}Response:
{
"email": "jan.kowalski@firma.pl",
"result": "deliverable",
"mx": "mx.firma.pl",
"smtp_code": 250,
"catch_all": false,
"duration_ms": 342
}POST /verify/batch
Content-Type: application/json
Request:
{
"emails": [
"jan.kowalski@firma.pl",
"j.kowalski@firma.pl",
"kowalski.jan@firma.pl"
]
}Response:
{
"results": [
{"email": "jan.kowalski@firma.pl", "result": "deliverable", "mx": "mx.firma.pl", "smtp_code": 250, "catch_all": false, "duration_ms": 342},
{"email": "j.kowalski@firma.pl", "result": "undeliverable", "mx": "mx.firma.pl", "smtp_code": 550, "catch_all": false, "duration_ms": 85},
{"email": "kowalski.jan@firma.pl", "result": "undeliverable", "mx": "mx.firma.pl", "smtp_code": 550, "catch_all": false, "duration_ms": 78}
],
"domain": "firma.pl",
"mx": "mx.firma.pl",
"catch_all": false,
"total_duration_ms": 505
}Batch optimization: emails sharing the same domain resolve MX once and reuse a single SMTP connection. Maximum 50 emails per batch. Automatic reconnect if the server drops the connection mid-batch.
GET /health
Response:
{"status": "ok"}GET /version
Response:
{"version": "1.0.0", "commit": "abc1234", "date": "2026-04-02T12:00:00Z"}| Result | Meaning |
|---|---|
deliverable |
RCPT TO returned 250, address exists |
undeliverable |
RCPT TO returned 550/551/552/553, address doesn't exist |
catch_all |
Server accepts any address (verification unreliable) |
unknown |
Couldn't determine (connection failed, timeout, greylisting, etc.) |
no_mx |
Domain has no MX records |
All via environment variables. You can also use a .env file (see .env.example). Real environment variables always take precedence over .env file values.
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP server port |
SMTP_TIMEOUT |
10s |
Per-connection SMTP timeout (Go duration format: 5s, 30s, 1m) |
HELO_DOMAIN |
localhost |
Domain announced in EHLO/HELO command |
MAIL_FROM |
probe@localhost |
Sender address for MAIL FROM |
LOG_LEVEL |
info |
Log level: debug, info, warn, error |
API_KEYS |
(empty) | Comma-separated list of valid API keys. If not set, API is open |
Example:
cd src
PORT=3000 \
SMTP_TIMEOUT=30s \
HELO_DOMAIN=probe.example.com \
MAIL_FROM=verify@example.com \
LOG_LEVEL=debug \
API_KEYS=key-abc-123,key-def-456 \
./mailprobeOr with a .env file:
cd src
cp .env.example .env
# edit .env with your values
./mailprobesrc/
cmd/mailprobe/main.go Entry point, .env loading, config, server, graceful shutdown
internal/config/config.go Config struct + Load() from environment variables
internal/config/dotenv.go Stdlib-only .env file parser
internal/smtp/models.go Result constants + VerifyResult, BatchVerifyResponse structs
internal/smtp/prober.go SMTP RCPT TO probing (MX resolution, TCP connect, handshake)
internal/api/handler.go HTTP handlers, validation, middleware (auth, logging, recovery)
internal/version/version.go Build version info (ldflags + BuildInfo fallback)
go.mod Go module definition
.env.example Example environment variable configuration
doc/img/mailprobe.png Project logo
Dockerfile Multi-stage build, Alpine-based, <10MB
docker-compose.yml Docker Compose for local development
.goreleaser.yml GoReleaser config for cross-platform releases
docker pull ghcr.io/sonquer/mailprobe:latestdocker run -p 8080:8080 \
-e HELO_DOMAIN=probe.example.com \
-e MAIL_FROM=verify@example.com \
-e API_KEYS=my-secret-key \
ghcr.io/sonquer/mailprobe:latestdocker compose up -dConfiguration is set via environment variables in docker-compose.yml. To override locally, create a docker-compose.override.yml (gitignored) or use an .env file.
docker build -t mailprobe .
docker run -p 8080:8080 mailprobeThe image is built with a multi-stage Dockerfile. Final image is Alpine-based and under 10MB. Available for linux/amd64 and linux/arm64.
Step-by-step guide to run mailprobe as a persistent service that starts automatically after server reboot.
1. Pull the image
docker pull ghcr.io/sonquer/mailprobe:latest2. Create a directory for the configuration
sudo mkdir -p /opt/mailprobe3. Create an environment file
sudo tee /opt/mailprobe/.env > /dev/null <<'EOF'
PORT=8080
SMTP_TIMEOUT=10s
HELO_DOMAIN=probe.example.com
MAIL_FROM=verify@example.com
LOG_LEVEL=info
API_KEYS=your-secret-api-key
EOF
sudo chmod 600 /opt/mailprobe/.env4. Run the container with restart policy
docker run -d \
--name mailprobe \
--restart unless-stopped \
--env-file /opt/mailprobe/.env \
-p 8080:8080 \
ghcr.io/sonquer/mailprobe:latestThe --restart unless-stopped policy ensures the container starts automatically after a Docker daemon restart or server reboot, unless it was explicitly stopped with docker stop.
5. Verify it is running
docker ps --filter name=mailprobe
curl http://localhost:8080/health6. View logs
docker logs mailprobe
docker logs -f mailprobe # follow live7. Update to a new version
docker pull ghcr.io/sonquer/mailprobe:latest
docker stop mailprobe
docker rm mailprobe
docker run -d \
--name mailprobe \
--restart unless-stopped \
--env-file /opt/mailprobe/.env \
-p 8080:8080 \
ghcr.io/sonquer/mailprobe:latestAlternative: use Docker Compose
Create a docker-compose.yml on your server:
services:
mailprobe:
image: ghcr.io/sonquer/mailprobe:latest
ports:
- "8080:8080"
env_file:
- .env
restart: unless-stoppedThen run:
docker compose up -dTo update:
docker compose pull
docker compose up -dClient (mailprobe) MX Server
| |
|--- TCP connect :25 ---------> |
|<-- 220 greeting ------------- |
|--- EHLO probe.example.com --> |
|<-- 250 OK ------------------- |
|--- MAIL FROM:<probe@...> ---> |
|<-- 250 OK ------------------- |
|--- RCPT TO:<user@domain> ---> |
|<-- 250 OK / 550 Not found --- |
|--- RSET --------------------> |
|<-- 250 OK ------------------- |
|--- QUIT --------------------> |
|<-- 221 Bye ------------------ |
Before probing real addresses, mailprobe sends RCPT TO for a randomly generated nonexistent address (zxqj_{random}@domain). If the server accepts it with 250, the domain is flagged as catch-all and all results for that domain return catch_all.
For emails sharing the same domain, mailprobe:
- Resolves MX once
- Opens one SMTP connection
- Runs catch-all detection
- Sends
RCPT TOfor each email, withRSETbetween probes - Reconnects automatically on connection drops
- Sends
QUITwhen done
cd src
go test -v -race -cover ./...All tests use mock SMTP servers on localhost. No external network access required. The test suite covers:
- Configuration parsing and defaults
- .env file loading with precedence rules
- API key authentication middleware
- HTTP request validation (method, content-type, body format)
- SMTP probing (deliverable, undeliverable, catch-all, timeout, greylisting)
- Batch operations with connection reuse
- Result ordering preservation
- Middleware (auth, logging, panic recovery)
- Full integration tests (HTTP -> SMTP -> response)
- CI: Runs
go vet, tests with race detector, and build on every push and PR to main - Release: On version tag push (
v*), GoReleaser creates a GitHub Release with cross-platform binaries and changelog, and a separate job builds and pushes the multi-platform Docker image to GHCR
git tag v1.0.0
git push origin v1.0.0This triggers the release workflow which:
- Creates a GitHub Release with downloadable binaries (linux/darwin, amd64/arm64), checksums, and auto-generated changelog
- Builds and publishes Docker images:
ghcr.io/sonquer/mailprobe:1.0.0,:1.0,:1, and:latest
- No rate limiting - handle externally (nginx, cloud provider, etc.)
- No persistent storage
- No email sending capability
MIT
