01Overview
A privacy-preserving alternative to ZeroBounce / NeverBounce. Runs on your own infrastructure, never sends mail, and exposes one clean HTTP API.
Each address goes through a five-stage pipeline. The result is a
single flat JSON object — every field at the top level next to
is_reachable, no nested syntax /
mx / smtp / misc /
debug sub-objects.
Verdicts
safe mailbox exists and looks deliverable. risky deliverable but flagged (disposable, role, catch-all, full inbox). invalid syntax / domain / mailbox failure. unknown network or provider issue — try again later.
License
Dual-licensed: AGPL-3.0 for open source use, or a commercial license from Autter for proprietary software. See LICENSE.md.
02Quickstart
Run it locally
git clone https://github.com/sagnik11/email-checker.git
cd email-checker
npm install
npm run dev
# server now listening on http://127.0.0.1:8080
Validate an address
curl -X POST http://127.0.0.1:8080/v1/check_email \
-H 'content-type: application/json' \
-d '{"to_email":"someone@gmail.com"}'
const res = await fetch("http://127.0.0.1:8080/v1/check_email", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ to_email: "someone@gmail.com" }),
});
const data = await res.json();
console.log(data.is_reachable); // "safe" | "risky" | "invalid" | "unknown"
import requests
resp = requests.post(
"http://127.0.0.1:8080/v1/check_email",
json={"to_email": "someone@gmail.com"},
timeout=30,
)
print(resp.json()["is_reachable"])
Submit a bulk job
Bulk mode requires worker.enable = true, Postgres,
and RabbitMQ. See Self-hosting.
# 1. submit
curl -X POST http://127.0.0.1:8080/v1/bulk \
-H 'content-type: application/json' \
-H 'x-api-secret: YOUR_SECRET' \
-d '{"input":["alice@example.com","bob@example.com"]}'
# → { "job_id": 42 }
# 2. poll progress
curl http://127.0.0.1:8080/v1/bulk/42
# 3. download results when complete
curl 'http://127.0.0.1:8080/v1/bulk/42/results?format=csv' -o results.csv
03API reference
Seven endpoints. /health, /ready,
/version, and / are unauthenticated. All /v1/*
endpoints require an x-api-secret header when
header_secret is configured (see
Authentication).
Response — 200
{ "ok": true }
Response — 200
{
"ok": true,
"dependencies": {
"postgres": { "ok": true },
"rabbitmq": { "ok": true, "queue": "check_email" }
}
}
Response — 503
{
"ok": false,
"dependencies": {
"postgres": { "ok": false, "error": "..." },
"rabbitmq": { "ok": true, "queue": "check_email" }
}
}
Response — 200
{ "version": "0.2.0", "smtp_port": 25 }
smtp_port reflects the
EMAIL_CHECKER_SMTP_PORT env var (default 25). The
bundled web UI uses this to pre-fill the SMTP port input.
Request body
| Field | Type | Description |
|---|---|---|
| to_email * | string | Address to validate. Required. |
| check_gravatar | boolean | Fetch Gravatar avatar URL. |
| haveibeenpwned_api_key | string | HIBP API key. Falls back to EMAIL_CHECKER_HIBP_API_KEY. |
| proxy | object | SOCKS5 proxy. See ProxyConfig. |
| hello_name | string | EHLO domain (only honored when proxy is set). |
| from_email | string | MAIL FROM address (only honored when proxy is set). |
| smtp_timeout | integer | SMTP timeout in seconds (only honored when proxy is set). |
| smtp_port | integer | SMTP port (only honored when proxy is set; default 25). The EMAIL_CHECKER_SMTP_PORT env var wins over this. |
| yahoo_verif_method | string | One of api, headless, smtp. Default smtp. |
| hotmailb2c_verif_method | string | One of headless, smtp. Default smtp. |
proxy is unset, the
body-level hello_name, from_email,
smtp_timeout, and smtp_port are
ignored and the server's TOML config is used instead. This
preserves the original Rust backend's behavior.
Response — 200
{
"input": "someone@gmail.com",
"is_reachable": "safe",
"email_address": "someone@gmail.com",
"email_username": "someone",
"email_domain": "gmail.com",
"normalized_email": "someone@gmail.com",
"is_valid_syntax": true,
"syntax_suggestion": null,
"is_disposable_email": false,
"is_role_account": false,
"is_b2c_provider": true,
"gravatar_url": null,
"has_been_pwned": null,
"mx_accepts_mail": true,
"mx_records": ["gmail-smtp-in.l.google.com"],
"mx_preferred_host": "gmail-smtp-in.l.google.com",
"mx_preferred_priority": 5,
"mx_lookup_error_type": null,
"mx_lookup_error_message": null,
"smtp_can_connect": true,
"smtp_has_full_inbox": false,
"smtp_is_catch_all": false,
"smtp_is_deliverable": true,
"smtp_is_disabled_account": false,
"smtp_error_type": null,
"smtp_error_message": null,
"smtp_error_description": null,
"backend_name": "backend-dev",
"check_started_at": "2026-01-01T00:00:00.000Z",
"check_completed_at": "2026-01-01T00:00:01.200Z",
"check_duration_ms": 1200,
"check_duration_seconds": 1,
"check_duration_nanos": 200000000,
"verification_method_type": "smtp",
"verification_method_host": "gmail-smtp-in.l.google.com",
"verification_method_smtp_port": 25,
"verification_method_provider": "gmail",
"verification_method_chosen": "smtp",
"verification_method_requested": null,
"verification_method_fallback": null
}
Field-by-field reference: Schemas.
Errors
| Status | Body | When |
|---|---|---|
| 400 | { error: "to_email field is required." } | Missing field, missing/invalid x-api-secret |
| 429 | { error: "Rate limit ... exceeded ..." } | Throttled |
| 500 | { error: "..." } | Internal error during the check |
| 503 | { error: "..." } | Worker mode enabled but RabbitMQ/Postgres unavailable |
Same pipeline as POST /v1/check_email, but each
stage is streamed back as a Server-Sent Event the moment it
completes. Useful for UIs that want to show progress while the
SMTP probe runs.
Query parameters
| Param | Required | Description |
|---|---|---|
| email * | yes | Address to validate. |
| api_secret | conditional | Same value as the x-api-secret header. Required when the server is configured with a header secret and the request is from a browser EventSource (which can't send custom headers). |
Event sequence
| Event | When | data: payload |
|---|---|---|
syntax | Local syntax check | { is_valid_syntax, domain, username, address } |
mx | MX lookup completes | { accepts_mail, records, preferred, lookupError } |
smtp_connect | TCP/TLS to MX is open | { host, port } |
smtp_rcpt | Server replied to RCPT TO | { code, message } |
done | Pipeline finished | Full CheckEmailResponse |
error | Unexpected server error | { message } |
Stages that get short-circuited (invalid syntax → no
mx/smtp_*; MX failure → no
smtp_*) are simply omitted, then done
is sent with the final result. A : ping heartbeat
comment is emitted every 15 seconds.
Example — cURL
curl -N "http://127.0.0.1:8080/v1/check_email/stream?email=foo@example.com"
Example — Browser EventSource
const url = new URL("/v1/check_email/stream", window.location.origin);
url.searchParams.set("email", "foo@example.com");
const es = new EventSource(url);
es.addEventListener("syntax", (e) => console.log("syntax", JSON.parse(e.data)));
es.addEventListener("mx", (e) => console.log("mx", JSON.parse(e.data)));
es.addEventListener("smtp_connect", (e) => console.log("smtp_connect", JSON.parse(e.data)));
es.addEventListener("smtp_rcpt", (e) => console.log("smtp_rcpt", JSON.parse(e.data)));
es.addEventListener("done", (e) => { console.log("result", JSON.parse(e.data)); es.close(); });
es.addEventListener("error", (e) => { console.error(e); es.close(); });
POST /v1/check_email retains worker-mode RPC
behavior. Throttling and storage match the inline path.
Errors
| Status | Body | When |
|---|---|---|
| 400 | { error: "email query parameter is required." } | Missing email query parameter or missing/invalid auth |
| 429 | { error: "Rate limit ... exceeded ..." } | Throttled (returned as JSON before the stream opens) |
Requires worker.enable = true, Postgres, and a
reachable RabbitMQ broker.
Request body
| Field | Type | Description |
|---|---|---|
| input * | string[] | Addresses to validate. Required, non-empty. |
| webhook | object | Per-result webhook. See WebhookConfig. |
Response — 200
{ "job_id": 42 }
Response — 200
{
"job_id": 42,
"created_at": "2026-01-01T00:00:00.000Z",
"finished_at": null,
"total_records": 1000,
"total_processed": 532,
"summary": {
"total_safe": 410,
"total_risky": 64,
"total_invalid": 51,
"total_unknown": 7
},
"job_status": "running"
}
job_status is "completed" once
total_processed === total_records;
finished_at stays null until then.
Query parameters
| Param | Default | Description |
|---|---|---|
| format | json | One of json, csv. |
| limit | 50 (json), all (csv) | Max rows. |
| offset | 0 | Pagination offset. |
Returns 400 if the job is still running. Wait for
job_status === "completed".
Response — 200 (json)
{ "results": [ /* CheckEmailResponse, ... */ ] }
04Schemas
Mirrored from openapi.yaml. Use the
YAML spec to generate clients (Swagger UI, Redoc,
openapi-generator, etc.).
CheckEmailResponse
Verdict
| Field | Type | Description |
|---|---|---|
| input | string | Trimmed copy of the submitted to_email. |
| is_reachable | enum | safe · risky · invalid · unknown |
Syntax
| Field | Type | Description |
|---|---|---|
| email_address | string · null | Trimmed address. Null when syntax invalid. |
| email_username | string | Local part. |
| email_domain | string | Domain. |
| normalized_email | string · null | Canonical form (Gmail dots/aliases stripped). |
| is_valid_syntax | boolean | RFC-compliant syntax check. |
| syntax_suggestion | string · null | Typo correction (e.g. gnail.com → gmail.com). |
Misc
| Field | Type | Description |
|---|---|---|
| is_disposable_email | boolean | Throwaway / temporary domain. |
| is_role_account | boolean | Local part is role-based (info, admin, noreply, …). |
| is_b2c_provider | boolean | Consumer mailbox (Gmail, Yahoo, Outlook, …). |
| gravatar_url | string · null | Avatar URL when check_gravatar=true. |
| has_been_pwned | boolean · null | HIBP breach lookup result. |
MX
| Field | Type | Description |
|---|---|---|
| mx_accepts_mail | boolean | Domain has resolvable MX records. |
| mx_records | string[] | All MX hostnames (sorted by priority). |
| mx_preferred_host | string · null | Lowest-priority MX (used for the SMTP probe). |
| mx_preferred_priority | integer · null | Priority of the preferred MX. |
| mx_lookup_error_type | string · null | DNS error code (ENODATA, ENOTFOUND, …). |
| mx_lookup_error_message | string · null | Human-readable DNS error. |
SMTP
| Field | Type | Description |
|---|---|---|
| smtp_can_connect | boolean | Reached the SMTP greeting. |
| smtp_has_full_inbox | boolean | Mailbox over quota. |
| smtp_is_catch_all | boolean | Domain accepts any local part. |
| smtp_is_deliverable | boolean | RCPT TO accepted. |
| smtp_is_disabled_account | boolean | Mailbox disabled / suspended. |
| smtp_error_type | string · null | Error class (ECONNREFUSED, ETIMEDOUT, SmtpError, …). |
| smtp_error_message | string · null | Raw SMTP / socket error message. |
| smtp_error_description | string · null | Classifier tag: IpBlacklisted, NeedsRDNS, etc. |
Debug / metadata
| Field | Type | Description |
|---|---|---|
| backend_name | string | Backend identifier. |
| check_started_at | ISO-8601 | Pipeline start. |
| check_completed_at | ISO-8601 | Pipeline finish. |
| check_duration_ms | integer | Total ms. |
| check_duration_seconds | integer | Whole-second portion. |
| check_duration_nanos | integer | Nanosecond remainder. |
| verification_method_type | enum | smtp · headless · api · skipped |
| verification_method_host | string · null | Host used. |
| verification_method_smtp_port | integer · null | Port used. |
| verification_method_provider | string · null | gmail · yahoo · hotmailb2c · hotmailb2b · proofpoint · mimecast · everything_else |
| verification_method_chosen | string · null | Method actually run. |
| verification_method_requested | string · null | Method the caller asked for (if different). |
| verification_method_fallback | string · null | Fallback target when requested method was unavailable. |
ProxyConfig
SOCKS5 tunnel for the SMTP connection.
| Field | Type | Required | Description |
|---|---|---|---|
| host | string | yes | Proxy host. |
| port | integer | yes | Proxy port. |
| username | string | no | SOCKS5 user id. |
| password | string | no | SOCKS5 password. |
| timeout_ms | integer | no | Proxy-hop timeout in ms. |
WebhookConfig
Per-result webhook delivered by the worker for each address in a
bulk job. POSTs JSON
{ result: CheckEmailResponse, extra: any } to
on_each_email.url with the merged headers.
{
"webhook": {
"on_each_email": {
"url": "https://your-app/hooks/email-result",
"headers": { "authorization": "Bearer ..." },
"extra": { "list_id": "q4-cleanup" }
}
}
}
05Authentication
Send the configured secret with every protected request:
x-api-secret: YOUR_SECRET
When header_secret is unset, all routes are open.
When it's set, missing or wrong values return 400.
/health, /ready, /version,
and / are always unauthenticated.
allow_browser_without_secret = true (or
EMAIL_CHECKER__ALLOW_BROWSER_WITHOUT_SECRET=true) to
let same-origin browser requests skip the secret check. Used by
the bundled web UI so the secret never leaks to the page source.
The check verifies the request's Origin /
Referer host matches the server's Host.
06CORS
The default is * (all origins allowed). Restrict it
for production:
EMAIL_CHECKER__CORS__ORIGINS=https://app.example.com,https://www.app.example.com
Comma-separated string or JSON array. The server sends
Access-Control-Allow-Headers: content-type, x-api-secret,
authorization and a 24-hour preflight cache.
07Configuration
Loaded by src/config.ts in this priority order
(later overrides earlier):
- Built-in defaults
backend_config.toml(or path passed via--config)- Double-underscore env vars:
EMAIL_CHECKER__<SECTION>__<KEY> PORTenv var →http_portfallback (Heroku / Fly / Render)
Common settings
| Env var | TOML key | Default | Notes |
|---|---|---|---|
| EMAIL_CHECKER__HTTP_HOST | http_host | 127.0.0.1 | Bind address |
| EMAIL_CHECKER__HTTP_PORT | http_port | 8080 | PORT also accepted |
| EMAIL_CHECKER__HEADER_SECRET | header_secret | — | API secret for x-api-secret |
| EMAIL_CHECKER__ALLOW_BROWSER_WITHOUT_SECRET | allow_browser_without_secret | false | Same-origin browser bypass |
| EMAIL_CHECKER__HELLO_NAME | hello_name | example.com | EHLO domain |
| EMAIL_CHECKER__FROM_EMAIL | from_email | noreply@example.com | MAIL FROM address |
| EMAIL_CHECKER__SMTP_TIMEOUT | smtp_timeout | 15s | SMTP timeout (seconds) |
| EMAIL_CHECKER__BACKEND_NAME | backend_name | backend-dev | Label in debug output |
| EMAIL_CHECKER__CORS__ORIGINS | cors.origins | ["*"] | Comma-list or JSON array |
Throttling
| Env var | TOML key |
|---|---|
| EMAIL_CHECKER__THROTTLE__MAX_REQUESTS_PER_SECOND | throttle.max_requests_per_second |
| EMAIL_CHECKER__THROTTLE__MAX_REQUESTS_PER_MINUTE | throttle.max_requests_per_minute |
| EMAIL_CHECKER__THROTTLE__MAX_REQUESTS_PER_HOUR | throttle.max_requests_per_hour |
| EMAIL_CHECKER__THROTTLE__MAX_REQUESTS_PER_DAY | throttle.max_requests_per_day |
Worker mode
| Env var | TOML key | Default |
|---|---|---|
| EMAIL_CHECKER__WORKER__ENABLE | worker.enable | false |
| EMAIL_CHECKER__WORKER__RABBITMQ__URL | worker.rabbitmq.url | amqp://guest:guest@localhost:5672 |
| EMAIL_CHECKER__WORKER__RABBITMQ__CONCURRENCY | worker.rabbitmq.concurrency | 5 |
| EMAIL_CHECKER__STORAGE__POSTGRES__DB_URL | storage.postgres.db_url | — |
Single-underscore aliases
These bypass the TOML overlay and are read directly by the checker. Useful for cloud providers that pass env vars through a flat namespace.
| Env var | Used by |
|---|---|
| EMAIL_CHECKER_SMTP_PORT | SMTP probe port. Wins over body-level smtp_port. Useful when port 25 is blocked. |
| EMAIL_CHECKER_HIBP_API_KEY | HaveIBeenPwned API key fallback. |
| EMAIL_CHECKER_FROM_EMAIL | MAIL FROM fallback. |
| EMAIL_CHECKER_HELLO_NAME | EHLO domain fallback. |
| EMAIL_CHECKER_BACKEND_NAME | Backend label fallback. |
| SMTP_DEBUG | Set to true for verbose JSON-line logging of the SMTP transaction. |
Inactive / stub config fields
Present in defaultConfig() but not wired up in
this codebase. Leave them blank.
sentry_dsn— config field exists but no Sentry SDK is installed.commercial_license_trial— placeholder for the hosted commercial trial integration.webdriver_addr/webdriver— stubs from a removed headless-browser verification mode.overrides.proxies— currently unused.
08Deployment
docker build -t email-validator .
docker run -p 8080:8080 \
-e EMAIL_CHECKER__HTTP_HOST=0.0.0.0 \
-e EMAIL_CHECKER__HEADER_SECRET=YOUR_SECRET \
email-validator
Multi-stage build, Node 20 Alpine, exposes 8080.
See Dockerfile.
fly launch --no-deploy
fly secrets set \
EMAIL_CHECKER__HEADER_SECRET=YOUR_SECRET \
EMAIL_CHECKER__ALLOW_BROWSER_WITHOUT_SECRET=true
fly deploy
Full step-by-step: FLY_DEPLOYMENT.md.
Default region sin, 512 MB shared CPU, HTTPS forced,
min_machines_running = 1.
api/index.ts wraps the Express app as a serverless
function. vercel.json routes everything to it.
1 GB / 30 s limit.
- Push the repo to GitHub.
- Import into Vercel.
- Set Node.js runtime to 18+.
- Add env vars in Project Settings.
npm install
npm run build
EMAIL_CHECKER__HTTP_HOST=0.0.0.0 \
EMAIL_CHECKER__HEADER_SECRET=YOUR_SECRET \
npm start
Pair with systemd, PM2, or a process supervisor of
your choice. Reverse-proxy via Caddy / nginx for HTTPS.
If outbound port 25 is blocked by your provider, use
EMAIL_CHECKER_SMTP_PORT=587 or route through a
SOCKS5 proxy (below).
09Self-hosting
Worker mode
Async bulk processing requires a worker, RabbitMQ, and Postgres. The API and worker can run as a single inline process or as separate scaled processes.
Inline (single process)
# with worker.enable = true in backend_config.toml
email-validator serve --config ./backend_config.toml
Separate processes (recommended for production)
# API
EMAIL_CHECKER__WORKER__ENABLE=false email-validator serve
# Worker (any number of replicas)
email-validator worker --config ./backend_config.toml
Workers consume from the check_email RabbitMQ queue
with prefetch = worker.rabbitmq.concurrency (default
5). Single-shot RPC checks have higher priority than bulk job
tasks. Transient SMTP failures (unknown) are
requeued once before being persisted.
Postgres schema
src/storage/postgres.ts auto-migrates on startup.
Two tables:
v1_bulk_job—id,created_at,total_records.v1_task_result—id,job_id,created_at,result(jsonb),extra(jsonb),error.
Aggregate progress is computed on the fly — no separate counters to keep in sync.
RabbitMQ
- Queue:
check_email(durable, priority-aware). - Single-shot RPC pattern uses an exclusive reply queue per request.
- Concurrency knob:
worker.rabbitmq.concurrency.
SOCKS5 proxy
When the host blocks port 25 outbound (most clouds do), route SMTP through a SOCKS5 proxy:
{
"to_email": "someone@example.com",
"proxy": {
"host": "proxy.local",
"port": 1080,
"username": "user",
"password": "pass",
"timeout_ms": 20000
},
"smtp_port": 25
}
Or use a different port server-side:
EMAIL_CHECKER_SMTP_PORT=587.
Observability
- Logs are JSON-lines on stdout. Set
SMTP_DEBUG=truefor verbose SMTP transaction logs. - Liveness probe:
GET /health; readiness probe:GET /ready. - Queue depth: monitor RabbitMQ's management UI / Prometheus exporter.
- Job status: query
v1_task_resultdirectly or pollGET /v1/bulk/{id}.
10Architecture
Verification pipeline
Orchestrated in src/checker/checkEmail.ts. Each
stage may short-circuit the rest.
1. Syntax — src/checker/syntax.ts
- RFC 5321/5322 validation via
isemail. - Gmail / Googlemail normalization: dots stripped,
+aliasremoved; canonical form goes intonormalized_email. - Typo suggestions via Levenshtein distance < 3 against
gmail.com,yahoo.com,outlook.com,hotmail.com,protonmail.com,icloud.com,yandex.com.
2. Misc — src/checker/misc.ts
- Disposable: union of
mailchecker+disposable-email-domains(~96k entries) + a small hardcoded override set. Domain and parent suffixes both checked. - Role: lowercased local part looked up in
src/data/roles.txt(944 entries). - B2C: domain looked up in
src/data/b2c.txt(~96,600 entries). - Gravatar: optional. MD5 hash +
?d=404probe, 6 s timeout. - HIBP: optional. v3 API, 10 s timeout. Returns
true/false/nullon error.
3. MX — src/checker/mx.ts
node:dns/promisesresolveMx(); sorted by priority ascending.- Soft DNS errors (
ENODATA,ENOTFOUND,NXDOMAIN,SERVFAIL) yieldaccepts_mail: falseandinvalid. - Hard DNS errors throw → result becomes
unknown.
4. SMTP — src/checker/smtp.ts · smtpParser.ts · provider.ts
- Provider detected from MX hostname:
gmail,yahoo,hotmailb2c,hotmailb2b,proofpoint,mimecast,everything_else. - TCP socket (or SOCKS5 tunnel). Port 465 → TLS automatically.
- Sequence:
220greeting →EHLO→MAIL FROM→ optional random RCPT (catch-all probe) → real RCPT →QUIT. - Errors classified by
smtpParser.ts: invalid mailbox, full inbox, disabled account, blacklisted IP, needs reverse DNS. - Retry policy: configurable
retries(default 1) on unclassified errors.
5. Reachability — calculateReachable()
Evaluated in this order:
| Verdict | Condition |
|---|---|
| unknown | An SMTP error is present (timeout, blacklist, rDNS, …) |
| risky | is_disposable · is_role_account · is_catch_all · has_full_inbox |
| invalid | !is_deliverable · !can_connect_smtp · is_disabled |
| safe | None of the above |
Syntax failure short-circuits to invalid before the
scoring matrix runs.
SMTP rules engine
src/data/rules.json stores per-domain and per-MX
overrides applied in src/checker/rules.ts:
SkipCatchAll— skip the random-address probe (Gmail, Hotmail, Yahoo always accept random RCPTs and would otherwise look catch-all).SmtpTimeout45s— bump the SMTP timeout to ≥ 45 s for slow / greylisting servers.
Repository layout
src/
checker/ pipeline (syntax, misc, mx, smtp, scoring, rules, provider)
http/ Express routes + auth + CORS
worker/ RabbitMQ queue, consumer loop, task service
storage/ storage abstraction + Postgres impl (auto-migration)
data/ roles.txt, b2c.txt, rules.json (loaded as in-memory Sets)
config.ts TOML + env precedence
runtime.ts wires config + storage + throttle + rabbit
server.ts HTTP entrypoint
bin/email-validator.ts CLI (serve | worker | check)
api/index.ts Vercel wrapper
public/ static assets (web UI, docs, openapi.yaml)
test/ node:test suite
11Contributing
Patches, bug reports, and feature requests welcome. Full guide: CONTRIBUTING.md.
git clone https://github.com/sagnik11/email-checker.git
cd email-checker
npm install
npm run dev # tsx-based dev server
npm test # node:test runner
Tests live in test/ and cover syntax, SMTP parser,
reachability scoring, config loading, and throttling. New
features should land with tests where practical.
12License
AGPL-3.0-or-later for open source use, or a commercial license from Autter Pte. Ltd. for proprietary software. See LICENSE / LICENSE.md.