Developers · September 28, 2026
Validating an Address Before Your Agent Sends
How to validate an email address before your agent sends: the syntax and DNS checks that are safe to run, and why probing the mailbox is not.
An agent pulls ann@gmial.com out of a forwarded thread and calls its send tool. The
API returns HTTP 202 with "result": "queued", the agent tells the user the email went
out, and nobody ever reads it. Or the agent passes Ann Lee <ann@example.com> as the
address, the request fails with a 400, and the model responds by trying three
variations of the address it made up. Both are validation failures, and they need
different fixes.
This article settles what it means to validate an email address before your agent sends. There are three separate checks, and only two of them are safe to run before a send. You get a runnable syntax and DNS check for the agent’s tool layer, an exact account of what the send endpoint already rejects, and what a bounce tells you afterwards. Whether the address should be trusted at all, given where the agent got it, is a different question, covered in when your agent invents a recipient address.
Three checks that all get called validation
Validation is three checks with different costs and different truth values. Syntax asks
whether the string is a well-formed address. Domain resolution asks whether the part
after the @ can receive mail at all. Mailbox existence asks whether a specific person
is behind the part before the @. Validation vendors sell all three as one score, and
that is where the confusion starts.
| Check | What it proves | How | Safe before a send? |
|---|---|---|---|
| Syntax | The string can be put in an SMTP envelope | A regex, locally | Yes, always |
| Domain resolution | Some host is willing to be asked for this domain | DNS: MX, then A/AAAA | Yes |
| Mailbox existence | Probably nothing you can rely on (see below) | SMTP RCPT TO without DATA | No |
The first two are deterministic and cheap, and a failure is a real answer: a domain that returns NXDOMAIN or publishes a null MX has told every sender not to try. The third returns an opinion from a server with several reasons to lie, and asking costs you reputation. Run the first two in the agent’s tool layer before every send, and let the send itself be the mailbox check.
What the send endpoint already rejects
The send endpoint validates syntax and nothing else about the address. It does not
resolve the recipient’s domain, and it does not contact the recipient’s server before
submission. Everything below is from the request schema (recipientSchema in the
contracts package), which both POST /v1/projects/{project_id}/inboxes/{inbox_id}/messages
and the MCP send_message tool parse against.
Each recipient is an object, {"email": "...", "name": "..."}, where email must pass
Zod’s email check, be at most 320 characters, and contain no CR or LF. A failure returns
HTTP 400 invalid_request with the offending path in details.fields:
{
"error": {
"code": "invalid_request",
"message": "The request does not match the required format. Check the fields and JSON syntax.",
"request_id": "req_...",
"retryable": false,
"details": { "fields": ["to.1.email"] }
}
}
Hand that path back to the model as “recipient 2 in to is not a valid address”, not as
a generic failure, or the model will rewrite the whole message.
The accepted shape is narrower than RFC 5322 allows. We ran these through the schema:
| Input | Result | Why it matters |
|---|---|---|
Ann.Lee+billing@Example.COM | accept | Plus tags and mixed case are fine |
o'brien@example.com | accept | Apostrophes are allowed in the local part |
ann@xn--bcher-kva.de | accept | Internationalised domains work in their ASCII (A-label) form |
ann@bücher.de | reject | The same domain in Unicode form does not |
josé@example.com | reject | No UTF-8 local parts (SMTPUTF8 addresses) |
"ann lee"@example.com | reject | RFC-valid quoted local parts are not accepted |
a#b@example.com, a=b@... | reject | Only letters, digits and _ ' + - . in the local part |
ann@[192.168.0.1] | reject | No IP address literals |
ann@localhost, ann@example | reject | The domain needs a dot and an alphabetic TLD |
ann..lee@example.com | reject | No leading, trailing or consecutive dots |
Ann Lee <ann@example.com> | reject | Display-name form goes in name, not email |
ann@gmial.com | accept | Well-formed. Syntax cannot know it is a typo |
ann@example.invalid | accept | Well-formed. Syntax cannot know the TLD is reserved |
Two more rules apply after the schema. The schema does not trim, so a stray leading or
trailing space is a 400; trim before you send. Addresses are then normalised by
lowercasing the domain only; the local part keeps its case, because RFC 5321 leaves the
local part’s case to the receiving server. Then the unique normalised addresses across
to, cc and bcc are counted, and more than 25 is a 400 invalid_request with the
message “Too many recipients.” The same address in to and cc counts once. Ann@x.com
and ann@x.com count twice, so lowercase the local part yourself if your data source is
inconsistent about case, and be consistent about it, because the suppression list is
keyed on the same normalised form.
For live inboxes, one more check runs before anything is queued: every recipient is looked up on the suppression list. That is not validation in the sense of this article, it is memory of an earlier bounce, and it gets its own section below.
Syntax: be exactly as strict as the API, then stop
The right syntax check for an agent’s tool layer is the one that accepts exactly what the send endpoint accepts. Stricter, and you block addresses that would have worked. Looser, and the check passes something the API then rejects with a less useful error. Do not reach for a fully RFC 5322 compliant parser: it will happily approve quoted local parts and comments that the endpoint refuses and that plenty of receiving servers mishandle anyway.
Three rules for the syntax step:
- Reject, do not repair. If the model produced
Ann Lee <ann@example.com>, splitting out the address is fine, because that is a parse of well-formed input. If it producedann@gmailcom, inserting the dot is a guess, and a guessed address is exactly the failure you are guarding against. Return the reason and let a human or the source document supply the correction. - Convert Unicode domains, do not reject them.
bücher.deandxn--bcher-kva.deare the same domain. Node’surl.domainToASCIIand Python’sidnacodec produce the A-label, which the API accepts. - Cap the local part at 64 characters yourself. RFC 5321 sets that limit and the API schema does not enforce it separately (it caps the whole address at 320), so an over-length local part passes the API and fails at the receiving server instead.
How to validate an email address before your agent sends: the DNS check
The domain check is a DNS lookup, and it has three outcomes that matter. The domain
publishes MX records: mail can be delivered, carry on. The domain publishes a null MX, a
single MX record with preference 0 and an exchange of .: RFC 7505 says the domain
accepts no mail, and a sender should treat the address as undeliverable on the first
attempt rather than queueing it. The domain publishes no MX at all: RFC 5321 section 5.1
says to treat the domain’s own A or AAAA record as an implicit MX with preference 0, so
it is deliverable if that host exists.
That third rule is the one most homemade checks get wrong. A check that fails any domain
without MX records rejects valid addresses at small domains that receive mail on the web
host. A check that forgets the null MX case approves example.com, which publishes one.
Distinguish permanent answers from temporary ones. NXDOMAIN (ENOTFOUND in Node) and
“no records of this type” (ENODATA) are answers. A SERVFAIL or a timeout is the absence
of an answer, and calling an address invalid because your resolver hiccupped teaches the
model that a good address is bad. Surface a temporary failure as “could not check”, not
as “invalid”.
Here is the whole check, with no dependencies beyond Node itself. It runs in your code, between the model’s tool call and the API request. It is not part of the Email for Agents API and does not call it.
// check-recipient.mjs: runs in your tool layer, before the send call. Not part of any API.
import { promises as dns } from "node:dns";
import { domainToASCII, pathToFileURL } from "node:url";
// Mirrors the shape the send endpoint accepts, so nothing passes here and fails there.
const ADDRESS = /^(?!\.)(?!.*\.\.)[A-Z0-9_'+.-]*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i;
export function checkSyntax(raw) {
const email = raw.trim();
if (/[\r\n]/.test(email)) return { ok: false, reason: "crlf" };
if (/[<>]/.test(email)) return { ok: false, reason: "display_name_form" };
const at = email.lastIndexOf("@");
if (at <= 0) return { ok: false, reason: "no_at_sign" };
const local = email.slice(0, at);
const domain = domainToASCII(email.slice(at + 1)); // bücher.de -> xn--bcher-kva.de
if (!domain) return { ok: false, reason: "bad_domain" };
if (local.length > 64) return { ok: false, reason: "local_part_too_long" };
const ascii = `${local}@${domain.toLowerCase()}`;
if (ascii.length > 320) return { ok: false, reason: "too_long" }; // A-labels are longer
if (!ADDRESS.test(ascii)) return { ok: false, reason: "unsupported_syntax" };
return { ok: true, email: ascii, domain: domain.toLowerCase() };
}
const PERMANENT = new Set(["ENOTFOUND", "ENODATA"]);
async function hasAddressRecord(domain) {
for (const lookup of [dns.resolve4, dns.resolve6]) {
try {
if ((await lookup(domain)).length) return true;
} catch (err) {
if (!PERMANENT.has(err.code)) throw err;
}
}
return false;
}
export async function checkDomain(domain) {
try {
const mx = await dns.resolveMx(domain);
// RFC 7505 null MX: one record, preference 0, exchange "." (Node reports "").
if (mx.length === 1 && mx[0].exchange === "" && mx[0].priority === 0) {
return { ok: false, reason: "null_mx", permanent: true };
}
return { ok: true, via: "mx", hosts: mx.sort((a, b) => a.priority - b.priority) };
} catch (err) {
if (err.code === "ENOTFOUND") return { ok: false, reason: "no_such_domain", permanent: true };
if (err.code !== "ENODATA") {
return { ok: false, reason: `dns_${err.code}`, permanent: false }; // SERVFAIL, timeout
}
}
// RFC 5321 section 5.1: no MX at all means the A/AAAA host is an implicit MX.
try {
if (await hasAddressRecord(domain)) return { ok: true, via: "implicit_mx" };
return { ok: false, reason: "no_mail_host", permanent: true };
} catch (err) {
return { ok: false, reason: `dns_${err.code}`, permanent: false };
}
}
export async function checkRecipient(raw) {
const syntax = checkSyntax(raw);
if (!syntax.ok) return { ...syntax, stage: "syntax", permanent: true };
return { email: syntax.email, stage: "domain", ...(await checkDomain(syntax.domain)) };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
for (const raw of process.argv.slice(2)) console.log(raw, await checkRecipient(raw));
}
Run it with node check-recipient.mjs ann@gmail.com ann@example.com. When we ran it on
28 September 2026, the results were:
| Address | Result |
|---|---|
ann@gmail.com | ok, via mx, five hosts |
ann@example.com | null_mx, permanent |
ann@thisdomaindoesnotexist-4471.com | no_such_domain, permanent |
ann@a91d7c22.mail.invalid | no_such_domain, permanent |
ann@bücher.de | ok as ann@xn--bcher-kva.de, via implicit_mx |
ann@gmial.com | ok, via implicit_mx |
Look at the last row. The typo domain has no MX record but does have an A record, so under RFC 5321 it is a legitimate mail destination and the check passes it. Typo domains are often registered for exactly this reason. DNS proves a domain can receive mail. It says nothing about whether this is the domain the user meant. Catching that requires knowing where the address came from: an address that appears verbatim in a message the agent received is a different thing from one the model composed. That is why the check belongs next to an allowlist or an approval rule, not instead of one; see restricting which domains an agent can email.
The .mail.invalid row is the pattern test inboxes use: .invalid is reserved and never
resolves, which is why a test inbox address cannot receive internet mail. If your checker
ever returns ok for a .invalid domain, your resolver is lying to you,
usually a captive portal or a DNS product that answers NXDOMAIN with an advertising page.
If your tool layer is Python, the same logic is a few lines with dnspython:
dns.resolver.resolve(domain, "MX") raises NXDOMAIN for a missing domain and
NoAnswer when the domain exists without MX records, which is your cue to try A and
AAAA. A null MX comes back as one record whose preference is 0 and whose exchange
is the root name.
Why not probe the mailbox with RCPT TO
A mailbox probe opens an SMTP connection to the recipient’s MX, sends EHLO,
MAIL FROM and RCPT TO:<ann@example.org>, reads the reply code, and hangs up before
DATA. A 250 is meant to mean the mailbox exists and a 550 that it does not. It is
the step validation services call “SMTP verification”, and the answer to whether an
agent’s tool layer should run it before every send is no.
The reply is unreliable in both directions:
- Catch-all domains answer
250for every local part, real or not, and bounce later or silently discard. Many business domains are configured this way on purpose so that probing reveals nothing. - Greylisting answers the first attempt from an unknown sender with a temporary
4xxand expects a retry minutes later (RFC 6647 describes the practice). A probe that hangs up reads that as “unknown” at best and “invalid” at worst. - Deferred verification is explicitly permitted. RFC 5321 section 3.3 notes that some
servers do not verify recipients until after the message text is received, and section
7.3 adds that where that happens,
RCPT“may return no information at all”. A server that accepts atRCPTand bounces afterwards is within the standard. - Anti-harvesting defences do the reverse: servers that see many
RCPTcommands without aDATAfrom one IP start refusing everything from it, valid addresses included.
It also costs you. Connections that check addresses and never deliver mail look like a directory-harvest attack, and the IPs that make them collect a reputation. Many cloud hosts restrict outbound port 25 anyway. Outsource the probe and you have handed the recipient list to another party and received a probability back.
You also cannot probe through Email for Agents: there is no endpoint that talks SMTP to a recipient’s server without sending a message. The only authoritative mailbox check is a real send to a real recipient, followed by reading what came back.
What the send result tells you, and what it does not
A successful send call proves the request was valid and stored. It does not prove
delivery, and it does not validate the address. The 202 queued result means the message
passed the schema, the policy check and, for a live inbox, the suppression check.
message.accepted means the sending provider took the submission. Neither says anything
about whether Ann’s mailbox exists, because at that point nobody has asked Ann’s server.
What each status actually proves walks through the
full state machine.
The answer to the mailbox question arrives later, per recipient, as a
message.delivery_updated event (trimmed here to the fields that matter):
{
"type": "message.delivery_updated",
"data": {
"message_id": "msg_...",
"recipient": "ann@example.org",
"status": "bounced"
}
}
status is one of delivered, delayed, bounced, suppressed, complained or
failed. The message’s recipients array holds a delivery_status per address,
starting at pending, and it never moves backward in rank, so treat the array as the
record and the event as the notification. Note what is not in that payload: no SMTP reply
code, no enhanced status such as 5.1.1, and no hard or soft bounce flag. You get the
outcome, not the diagnostic. That matters because a bounce is not always a bad address:
a 550 5.7.1 policy rejection means the receiving
server refused the message or the sender, and the mailbox may be perfectly real.
The hard-bounce distinction still shows up, one step later. When the provider reports a
permanent bounce, a complaint, or its own suppression, the address goes on the
workspace’s suppression list. A transient bounce updates the status without suppressing.
The next live send that includes a suppressed address is refused before it is queued,
with HTTP 422 recipient_suppressed, “A recipient is on the suppression list.”, and
retryable: false. That check covers every recipient in the request, so one suppressed
address in a 20-recipient send stops all 20. Remove it and resend; do not retry. The
check runs again at dispatch, and a queued send whose recipient was suppressed in the
meantime is set to canceled with no event, so poll the message state if a send seems
to have gone quiet.
message.failed is not the per-address signal. It is emitted when the whole message is
definitively rejected at submission, or when a queued send runs out of its retry window
(reason: "provider_retry_window_expired") or cannot build reply headers
(reason: "reply_parent_headers_not_available"). It carries a message_id and at most
a message-level reason, never an address. If you are waiting for a bounce, subscribe to
message.delivery_updated. When a bounce never arrives and the recipient still says
nothing came, the agent says it sent but nobody received it
covers the diagnosis order.
Wiring the checks into the agent’s send tool
The order matters, because each step is cheaper than the next and each failure needs a different message back to the model:
- Parse. Split display-name form into
emailandnamebefore the checker sees it (the checker above returnsdisplay_name_formif you forget). Trim whitespace. - Syntax, matched to the API. Convert Unicode domains to A-labels, enforce the
64-character local part, apply the same pattern the endpoint uses, and return the
reason (
unsupported_syntax,local_part_too_long) on failure. - Domain. MX, null MX, implicit MX. Permanent failures go back to the model as “this domain does not accept mail”. Temporary failures go back as “could not check, try again”, and the tool does not send.
- Policy. An allowlist of domains or addresses on the inbox is enforced server-side
whatever your tool layer does, and a send outside it fails with 403
policy_deniedandreason_codes: ["recipient_not_allowed"]. The sending controls docs show the policy shape. If the agent composes recipients rather than replying to known ones, put a human approval step in front of the send. - Send, with an
Idempotency-Keyderived from the business intent, so a retried tool call is not a second email. - Watch the outcome. Handle
400 invalid_requestby readingdetails.fields, handle422 recipient_suppressedby removing the address, and recordmessage.delivery_updatedper recipient.
What to tell the model at each failure is the part people skip. A model told “send failed” will often invent a plausible alternative address and try again, which turns one bad send into several. Return a structured reason, include the exact address that failed, and state that the address must come from the user or a source document rather than from a retry. The check is only as useful as the instruction that comes back with it.
None of this is a guarantee of delivery, and nothing is. A syntax check and a DNS check remove the addresses that could never work, which is most of what a pre-send check can honestly do. Everything else is learned from the bounce, per recipient, after the fact. The rest of the send surface, including events, webhooks and idempotency, is on the developer platform page.
Keep reading
Accepted vs Delivered: What an Email Status Proves
Queued, accepted, delivered, bounced, unknown: what each email status actually proves, what it does not, and the correct next action for every one of them.
My Agent Says It Sent It. Nobody Received It
Your AI agent says it sent an email and nobody received it. Work the states in order, learn which ones emit no event at all, and never retry one.
Restrict Which Domains Your Agent Can Email
Restrict which domains your agent can email with an allowlist it cannot argue with: the policy call, how recipients match, and what a denied send returns.