DEV Community

Low Key Builds
Low Key Builds

Posted on

How email verifiers work (SMTP RCPT in 60 lines of Node), and why they fail on 56% of startups

Email verification tools mostly do one thing under the hood: they open an SMTP connection to the domain's mail server and ask whether a mailbox exists, then hang up before sending anything. Here is how that works, in about 60 lines of Node, and what happened when I ran it against 499 Y Combinator companies.

Disclosure: I built MailFerret, a pay-per-call email finder for AI agents that uses this technique. The code below is the core idea, free to copy.

The SMTP conversation

Every domain that receives email publishes MX records. Connect to the first one on port 25 and you get a short dialogue:

S: 220 mx.google.com ESMTP
C: EHLO probe.example.com
S: 250-mx.google.com at your service
C: MAIL FROM:<verify@example.com>
S: 250 2.1.0 OK
C: RCPT TO:<paul@supabase.com>
S: 250 2.1.5 OK                      <- mailbox exists
C: RCPT TO:<qzxv81kd@supabase.com>
S: 550 5.1.1 The email account that you tried to reach does not exist
C: QUIT
Enter fullscreen mode Exit fullscreen mode

250 on RCPT TO means "I'll accept mail for this address". 550 5.1.1 means "no such user". You never send DATA, so no email is delivered.

Minimal Node implementation

import net from "node:net";
import { promises as dns } from "node:dns";

async function rcptCheck(domain, addresses) {
  const mx = (await dns.resolveMx(domain)).sort((a, b) => a.priority - b.priority)[0].exchange;
  return new Promise((resolve) => {
    const sock = net.createConnection({ host: mx, port: 25 });
    const out = {}; let step = 0, i = 0, buf = "";
    const send = (l) => sock.write(l + "\r\n");
    sock.setTimeout(12000, () => sock.destroy());
    sock.on("close", () => resolve(out));
    sock.on("data", (d) => {
      buf += d;
      const last = buf.trimEnd().split("\r\n").pop();
      if (!/^\d{3} /.test(last)) return;          // wait for the final line of a multi-line reply
      const code = +last.slice(0, 3); buf = "";
      if (step === 0) { step = 1; return send("EHLO probe.example.com"); }
      if (step === 1) { step = 2; return send("MAIL FROM:<verify@example.com>"); }
      if (step === 2) { step = 3; return send(`RCPT TO:<${addresses[0]}>`); }
      if (step === 4) return;                     // reply to QUIT
      out[addresses[i]] = code;
      if (++i < addresses.length) return send(`RCPT TO:<${addresses[i]}>`);
      step = 4; send("QUIT");
    });
  });
}

// First address is random: if the server accepts it, the domain is catch-all.
console.log(await rcptCheck("resend.com", ["qzxv81kd3j@resend.com", "zeno.rocha@resend.com"]));
// { 'qzxv81kd3j@resend.com': 550, 'zeno.rocha@resend.com': 250 }
Enter fullscreen mode Exit fullscreen mode

To find an address rather than check one, generate the usual patterns (first.last, first, flast, firstlast, f.last...) and RCPT them in order in one session. The first 250 on a domain that rejected the random address is your answer.

The catch: catch-all domains

Some domains accept mail for any address. Send RCPT TO:<anything@domain> and you get 250. On those domains SMTP verification can't tell real mailboxes from fake ones. That's why the snippet probes a random address first.

How common is that? I took 499 random active companies from the public yc-oss YC list and checked each website domain:

Count Share
Google Workspace 409 82%
Microsoft 365 23 5%
No MX on the website domain 43 9%
Catch-all (of 456 with mail) 254 56%
Verifiable (random address rejected) 177 39%
Refused to answer (Proofpoint, Cloudflare routing, SES inbound...) 25 5%

On Google Workspace alone, 60% of these startups had catch-all routing on. So for founders' addresses, more than half the "valid" results you get from any SMTP-based verifier are really "unknown". Full numbers and method: mailferret.appin60.com/study.

Things that bite in production

  • Port 25 is blocked on most cloud hosts (AWS, GCP, Azure, Vercel, Workers). You need a box where outbound 25 is open.
  • IP reputation matters. Cloudflare Email Routing answered our residential IP with 550 in the banner; Proofpoint and some others just time out. Treat those as "unknown", not "invalid".
  • Greylisting returns 4xx the first time. Retry later or report unknown. One useful exception: Google's 450 4.2.1 The user you are trying to contact is receiving mail at a rate... is only sent for mailboxes that exist.
  • Don't hammer one server. Batch all candidates into one session and cache catch-all results per domain.
  • Large TXT records (SPF/DMARC) can time out through some local stub resolvers; querying 1.1.1.1 / 8.8.8.8 directly fixed it for me.
  • Policy rejections aren't "user unknown". Parse the text: 5.1.1 / "does not exist" means no mailbox; "blocked", "Spamhaus", "policy" means they refused you.

If you'd rather not run it yourself

MailFerret wraps this as an HTTP API that AI agents pay per call with x402 (USDC on Base, no API key):

  • POST /find {first_name, last_name, domain}: $0.01, charged only when the mail server confirms the mailbox. Catch-all and not-found results are free.
  • POST /verify {email}: $0.003.
  • POST /domain {domain}: $0.005 for mail provider, catch-all status, SaaS tools revealed by SPF, DMARC, public emails and the likely pattern.

From Claude Code or Codex with AgentCash: npx agentcash fetch https://mailferret.appin60.com/find -m POST -b '{"first_name":"Zeno","last_name":"Rocha","domain":"resend.com"}'. There are 5 free lookups a day on the homepage.

Cleaning a whole list instead? The homepage has a bulk CSV option: $4 per 1,000 addresses in USDC, quoted before you pay.

Use it for legitimate B2B outreach, and follow CAN-SPAM/GDPR when you email anyone.

Top comments (0)