Skip to content

pypesdev/smtp-warmer

Repository files navigation

smtp-warmer

CI License: MIT

Diagnose why aren't my emails landing? for self-hosted SMTP in 30 seconds. Zero paid APIs. Zero runtime deps. npx-friendly.

npx smtp-warmer test --host smtp.example.com --port 587 --user me@example.com
Status  Check                          Summary
----------------------------------------------
PASS    TLS handshake                  Negotiated TLSv1.3 with TLS_AES_256_GCM_SHA384.
PASS    SMTP AUTH + RCPT sandbox       Auth + RCPT TO:<me@example.com> accepted. Session rolled back; no mail sent.
PASS    Sending IP reputation (DNSBL)  1 sending IP(s) clean across 4 DNSBLs.
PASS    Reverse DNS / PTR alignment    Forward-confirmed reverse DNS for all 1 sending IP(s).

Composite score: 10.0 / 10

Why

You're running your own SMTP relay (Postfix, Haraka, Postmark-via-VPN, whatever) and your mail keeps landing in spam. There are exactly four reasons this happens at the transport layer, and you don't need a $99/mo SaaS account to check any of them.

smtp-warmer test runs the four most common transport-layer deliverability checks against your relay and gives you a 0–10 score plus actionable remediation hints — without sending real mail.

What it checks

Offline (default — no mail sent)

Check What it does
TLS handshake Verifies cert validity, expiry, protocol version (TLS 1.2/1.3), and cipher strength.
SMTP AUTH + RCPT sandbox Logs in with your credentials and verifies a RCPT TO:<your-own-address> succeeds, then RSETs. No mail sent.
Sending IP reputation Reverses your sending IP and queries 4 free DNSBLs (Spamhaus ZEN, Barracuda, SpamCop, UCEPROTECT). No API key.
Reverse DNS / PTR alignment Verifies the sending IP has a PTR record and that the PTR forward-resolves back to the same IP (FCrDNS).

Live (--live — real mail is sent to your own inbox)

Check What it does
Bounce-loop / Return-Path Sends one envelope, reconnects via IMAP, polls for the message, and verifies Return-Path matches MAIL FROM. Catches SRS rewrites and bounce loops.
Rate-limit / throttling Pipelines N small envelopes (default 5, cap 20) and watches for 421/450/452 throttle codes and >2× monotonic RTT growth.

Both live checks refuse to run unless --bounce-recipient is your own inbox — its domain must match --ehlo (or --user), or be in a small allow-list (Gmail, Outlook, iCloud, Fastmail, Proton).

Every check is independent. Each has a per-check timeout (default 10s), and any check can be skipped with --skip or isolated with --only.

Install

# Run once, throw away
npx smtp-warmer test --host smtp.example.com --port 587 --user me@example.com

# Or install globally
npm i -g smtp-warmer
smtp-warmer test --host smtp.example.com --port 587 --user me@example.com

# Programmatic
npm i smtp-warmer
import { runChecks } from 'smtp-warmer'

const report = await runChecks({
  target: { host: 'smtp.example.com', port: 587, user: 'me@example.com', pass: process.env.SMTP_PASS },
})
console.log(`Composite score: ${report.composite}/10`)

Options

smtp-warmer test --host <h> --port <p> [options]

  --user <email>             SMTP login (also used as RCPT sandbox recipient)
  --pass <secret>            SMTP password (or read from $SMTP_PASS)
  --from <email>             Override MAIL FROM (default: --user)
  --rcpt <email>             Override RCPT TO sandbox (default: --user)
  --ehlo <hostname>          Override EHLO/HELO hostname (default: os.hostname())
  --skip <ids>               Comma-separated check ids to skip (tls|auth|reputation|reverseDns|bounceLoop|rateLimit)
  --only <ids>               Run only the listed comma-separated checks
  --timeout <ms>             Per-check timeout in milliseconds (default: 10000)
  --insecure-tls             Disable server-cert verification (dev only)
  --json                     Print machine-readable JSON
  --no-color                 Disable ANSI colors

  Live (real mail is sent — opt-in):
  --live                     Enable bounceLoop + rateLimit checks (sends mail)
  --bounce-recipient <email> Test recipient — must be your own inbox
  --rate-count <N>           Envelope count for rateLimit (default: 5, cap: 20)
  --imap-host <hostname>     IMAP server (default: smtp.* → imap.*)
  --imap-port <number>       IMAP port (default: 993, implicit TLS)
  --imap-user <user>         IMAP login (default: --user)
  --imap-pass <secret>       IMAP password (or read from $IMAP_PASS, then --pass)
  --imap-mailbox <name>      Mailbox to poll (default: INBOX)
  --bounce-poll-ms <ms>      How long to poll IMAP for the probe (default: 30000)

  -h, --help                 Show this help
  -v, --version              Show version

Live mode example

# Self-hosted Postfix → your own inbox on the same domain
smtp-warmer test --host smtp.example.com --port 587 \
  --user me@example.com --ehlo mail.example.com \
  --live --bounce-recipient me@example.com

# Gmail SMTP relay → Gmail inbox (with an app password)
smtp-warmer test --host smtp.gmail.com --port 587 \
  --user you@gmail.com --pass "$GMAIL_APP_PASS" \
  --live --bounce-recipient you@gmail.com --rate-count 3

The bounce-recipient validator refuses anything that doesn't look like the operator's own inbox — there's no mode that lets you point smtp-warmer at a third party.

Exit codes

Code Meaning
0 All checks passed (composite ≥ 8.0)
1 At least one warn or composite < 8.0
2 At least one fail
3 Bad arguments

CI-friendly:

- run: npx -y smtp-warmer test --host $SMTP_HOST --port 587 --user $SMTP_USER --json
  env:
    SMTP_PASS: ${{ secrets.SMTP_PASS }}

Where it sits in the pypesdev deliverability stack

Tool Layer When to reach for it
dmarc-doctor DNS records (SPF / DKIM / DMARC) "Are my domain's auth records published correctly?"
smtp-warmer (you are here) Live SMTP transport "Why does my own relay land in spam?"
inbox-warmer Ongoing reputation building "I just spun up a new domain and need to warm it for weeks."
coldflow Full cold-email platform "I want everything wired together — sequences, replies, tracking."

dmarc-doctor answers the DNS question once. smtp-warmer answers the live-transport question every time you change relays. inbox-warmer runs continuously for weeks.

Safety notes

  • The auth check never sends mail. It performs MAIL FROM + RCPT TO + RSET — the SMTP equivalent of "knock and walk away". The session is rolled back before any DATA command is issued.
  • The default RCPT sandbox is --user (your own address). If you pass --rcpt, you are responsible for ensuring it's a deliberate test recipient (your own inbox).
  • DNSBL lookups are pure DNS queries — they generate four DNS A-record lookups per sending IP and zero outbound mail.
  • The live checks (bounceLoop, rateLimit) do send real mail, but only when you pass --live and --bounce-recipient. The recipient is validated against --ehlo and a small allow-list of major mailbox providers; off-domain recipients are refused. The rateLimit check is hard-capped at 20 envelopes per run.
  • Live integration tests (LIVE_SMTP_TESTS=1 npm test) are gated behind env vars so the unit suite stays fast and offline-safe.

Development

npm install
npm run typecheck    # tsc --noEmit
npm test             # vitest run (offline, mocked)
npm run build        # tsc → dist/

Tests are split into:

  • Unit (test/): pure-function scoring, mocked SMTP/IMAP/DNS — runs in <1 second offline. 78 specs.
  • Live (test/live/, gated by LIVE_SMTP_TESTS=1): hits a real SMTP relay + IMAP mailbox. See test/live/README.md for env-var requirements and provider notes (Gmail-only for v0.2).
LIVE_SMTP_TESTS=1 \
  LIVE_SMTP_HOST=smtp.gmail.com \
  LIVE_SMTP_USER=you@gmail.com \
  LIVE_SMTP_PASS="$GMAIL_APP_PASS" \
  LIVE_SMTP_RCPT=you@gmail.com \
  npm test

License

MIT © pypesdev

About

Diagnose 'why aren't my emails landing?' for self-hosted SMTP in 30 seconds. TLS handshake, AUTH+RCPT sandbox, DNSBL, reverse-DNS — zero paid APIs, zero runtime deps, npx-friendly.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors