How to Verify an Email Address Without Sending Mail
In short
You verify an address by starting a delivery and stopping before the message is sent. The receiving server is asked whether it will accept a recipient, its answer is recorded, and the connection closes with nothing transmitted.
The question sounds like it should be impossible. You want to know whether a mailbox exists, and the obvious way to find out is to email it and see what happens, which is exactly what you were trying to avoid. Fortunately the protocol that carries mail is conversational, and a conversation can be abandoned partway through.
Why silence is the point
Sending a test message answers the question and costs you something to do it. A message to a mailbox that does not exist produces a hard bounce against your sending domain, which is the specific event that damages deliverability. A message to a mailbox that does exist announces you to a stranger before you had anything to say.
Probing avoids both. Nothing is queued, nothing is delivered, and the answer arrives in well under a second. The whole technique exists because the SMTP protocol asks the receiving server to agree to a recipient before the sender transmits anything, which means the agreement can be obtained and the transmission abandoned.
The alternatives people try first
Two other approaches come up, and both answer a different question. Double opt-in confirms an address by asking the owner to click a link, which is the strongest possible evidence and completely unavailable for a list of people who have never heard of you. Sending a deliberately trivial message and watching for a bounce does work, and it is simply the thing this technique exists to replace, since the bounce it produces is the exact event you were trying to avoid generating.
The five stages
A complete check works through five stages in order, stopping at the first that settles the question. The first three are cheap lookups and the last two require a network conversation.
- Syntax. Does the string parse as an address?
- Domain. Does the part after the @ resolve in DNS?
- Mail servers. Does that domain publish MX records nominating servers to receive its mail?
- Connection. Will the nominated server actually accept a TCP connection on the SMTP port?
- Recipient. Will it agree to accept mail for this specific mailbox?
The first four are mechanical and rarely interesting. Everything that makes verification difficult lives in the fifth.
The conversation, in full
Here is a complete successful exchange, from the server’s greeting to the close. Lines the client sends are marked with an arrow; everything else is the server replying.
(connect to mail.company.com on port 25)
< 220 mail.company.com ESMTP ready
> EHLO probe.example.com
< 250-mail.company.com
< 250-SIZE 52428800
< 250-8BITMIME
< 250 STARTTLS
> MAIL FROM: <check@probe.example.com>
< 250 2.1.0 Sender OK
> RCPT TO: <m.okafor@northbeam.io>
< 250 2.1.5 Recipient OK
(stop here: the answer has been obtained
and no DATA command is ever issued)
> QUIT
< 221 2.0.0 Service closing transmission channelThe decisive line is the response to RCPT TO. A 250 means the server will accept mail for that recipient. A 550 means it will not. Because the client never issues DATA, no message body is ever offered and nothing is delivered.
Note the sender address in MAIL FROM. It has to be a real, deliverable address on a domain you control, because a server that cannot verify the sender may refuse the recipient regardless. A probe with a fabricated return path fails more often and looks exactly like the behaviour of a spam operation.
Reading the response
SMTP replies are three-digit codes, and the first digit carries most of the meaning. Getting this taxonomy right is most of what separates a reliable implementation from one that quietly loses addresses.
- 2xx, accepted. A
250onRCPT TOmeans the server will take mail for this recipient. On an ordinary domain this is a real answer. On a domain that accepts everything it is not, which is the case discussed below. - 4xx, temporary. The server is refusing for now and inviting you to try later. Greylisting lives here, as does most rate limiting. Treating a 4xx as a negative verdict is the single most damaging mistake in a naive implementation, because it deletes addresses that were never established as bad.
- 5xx, permanent. A
550onRCPT TOmeans no such mailbox. Other 5xx codes may mean the server declined to answer for policy reasons rather than because the mailbox is missing, so the code alone is not always enough to distinguish refusal from rejection.
The subtlety is that 4xx and 5xx are not always used the way the specification suggests. Some servers return a permanent-looking code for a temporary condition, and some do the reverse. A production implementation ends up carrying per-provider knowledge about which codes to believe.
Doing it yourself
Below is a working implementation, and it is here to build intuition rather than to run in production. Read the section after it before you consider deploying anything like it.
import smtplib
import dns.resolver
def probe(address: str, helo_domain: str, mail_from: str):
"""Returns the SMTP code for RCPT TO, or None if unreachable."""
domain = address.split("@")[1]
# Stage 3: find the mail servers, lowest preference first
records = sorted(dns.resolver.resolve(domain, "MX"),
key=lambda r: r.preference)
host = str(records[0].exchange).rstrip(".")
# Stages 4 and 5: connect and ask, without sending
server = smtplib.SMTP(timeout=10)
server.connect(host, 25)
server.helo(helo_domain)
server.mail(mail_from)
code, _ = server.rcpt(address) # 250 accepted, 550 refused
server.quit()
return codeimport net from "node:net";
import { resolveMx } from "node:dns/promises";
// Educational. Production use needs timeouts per stage, TLS handling,
// connection pooling and per-host rate limiting.
export async function probe(address, heloDomain, mailFrom) {
const domain = address.split("@")[1];
const mx = (await resolveMx(domain)).sort((a, b) => a.priority - b.priority);
return new Promise((resolve, reject) => {
const socket = net.createConnection(25, mx[0].exchange);
const script = [
`EHLO ${heloDomain}`,
`MAIL FROM: <${mailFrom}>`,
`RCPT TO: <${address}>`,
"QUIT",
];
let step = -1;
socket.setTimeout(10_000, () => { socket.destroy(); reject(new Error("timeout")); });
socket.on("data", (chunk) => {
const code = parseInt(chunk.toString().slice(0, 3), 10);
if (step === 2) resolve(code); // the RCPT TO answer
if (++step < script.length) socket.write(script[step] + "\r\n");
});
socket.on("error", reject);
});
}Both will work from a laptop on a permissive network, against a cooperative server, for a handful of addresses. That is the honest extent of it.
Why this stops working
The technique is sound and the implementation is not the hard part. What breaks is everything around it.
Port 25 is blocked almost everywhere
Outbound connections on port 25 are blocked by default on essentially every residential ISP and every major cloud provider, because that port is how spam moves. Your code will run perfectly and the connection will simply never establish. Providers will sometimes lift the restriction on request, and they will want to know why.
Reputation decides your answers
Mail servers treat unknown senders differently from familiar ones. Infrastructure with no sending history gets rate limited sooner, refused more often, and in some cases given deliberately unhelpful responses. Two identical probes from different sources genuinely return different results, which means the quality of your answers depends on an asset you have to build over months.
Greylisting looks like failure
A server practising greylisting returns a temporary refusal in the 4xx range rather than an answer:
(first attempt)
> RCPT TO: <m.okafor@northbeam.io>
< 451 4.7.1 Try again later
(same address, after waiting the suggested interval)
> RCPT TO: <m.okafor@northbeam.io>
< 250 2.1.5 Recipient OKTreating that as undeliverable deletes a valid address. Handling it correctly means queuing the address, waiting, and returning later, which turns a stateless function into a scheduler with persistent state.
Catch-all domains cannot be answered at all
Some domains accept every recipient they are offered, so RCPT TO returns 250 for addresses that belong to nobody. No amount of care in conducting the probe changes this, because the limitation is in what the server will reveal. The mechanics of that failure are worth understanding, because on business lists this affects a quarter of addresses and it is the single largest reason DIY verification produces confident nonsense.
What a production implementation actually needs
The gap between the code above and something you would run against a real list is not cleverness, it is a long list of unglamorous concerns. Roughly in order of how soon each one bites:
- Timeouts at every stage. DNS, connection, and each command separately. A single unresponsive server will otherwise hold a worker open indefinitely, and one slow host can stall a whole run.
- MX fallback. Domains publish several mail servers with preferences for a reason. Trying only the first means treating a single host’s outage as the domain being unreachable.
- Per-host rate limiting. Probing one provider rapidly is the fastest way to get refused. Concurrency has to be bounded per destination host, not just globally.
- Retry scheduling with state. Greylisting means coming back in several minutes, which turns a stateless function into a queue with persistence and a scheduler.
- Connection reuse. Opening a fresh TCP connection per address is slow and conspicuous. Real implementations pipeline several recipients over one connection where the server allows it.
- A second technique for the addresses one probe cannot settle. This is the part that is not an engineering problem at all, and no amount of the above substitutes for it.
Where the line falls
Build it yourself when you are learning how mail works, when you need to check a handful of addresses occasionally, or when you want to understand what a vendor is actually doing on your behalf. It is a genuinely instructive exercise and the protocol is not complicated.
Use something else when results feed a business decision. The crossover is not about volume so much as consequence: the moment a wrong answer causes you to delete a real lead or mail a dead one, you need the parts that are tedious rather than clever. Sending infrastructure with reputation, retry scheduling for greylisting, per-host rate limiting, and a second technique for the addresses a single probe cannot settle.
That last one is the part you cannot write your way around, and it is why how verification works spends most of its time on the ambiguous segment rather than the mechanics. If you want the mechanics behind an interface instead, the API reference covers single and bulk verification with the same result schema, and check an address instantly runs the whole pipeline in a browser without an account. A free account includes 100 credits if you want to compare your own probe against it.
Frequently asked questions
Is probing a mail server this way legal?
The technique uses the SMTP protocol exactly as specified and transmits no message, so it is not interception or unauthorised access. What it does touch is data protection: an email address is personal data in most jurisdictions, so your basis for holding and processing it is a separate question from whether the probe itself is permissible. That question is about your list, not about the protocol.
Will the recipient know I checked their address?
No. The exchange happens between your machine and their mail server and ends before any message is queued, so nothing reaches the mailbox and nothing appears in the recipient's client. A determined administrator reading server logs would see a connection that stopped after the recipient command, which is indistinguishable from a great many ordinary events.
Why does my script work locally but not on a server?
Almost certainly port 25 egress. Most residential ISPs and every major cloud provider block outbound connections on port 25 by default, because it is the port spam runs on. The code is fine and the network is refusing to carry it. Cloud providers will occasionally lift the restriction on request, and typically want to know what you intend to do with it.
Can I just retry when a server refuses my probe?
For greylisting, yes, that is the intended behaviour: wait the interval the server suggests and ask again. For rate limiting, retrying quickly makes matters worse and can get your address blocked outright. The difficulty is telling the two apart from the response alone, which is one of the details that makes a naive implementation unreliable in production.
How many addresses can I verify this way before it breaks down?
There is no clean number, because the limit is reputational rather than technical. A handful of probes a day from one address attracts no attention. Thousands, from infrastructure with no sending history, will get you rate limited and then refused, and the results degrade before the failures become obvious. The failure mode is quiet answers that are wrong rather than errors you can catch.