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.

CheckWhat it provesHowSafe before a send?
SyntaxThe string can be put in an SMTP envelopeA regex, locallyYes, always
Domain resolutionSome host is willing to be asked for this domainDNS: MX, then A/AAAAYes
Mailbox existenceProbably nothing you can rely on (see below)SMTP RCPT TO without DATANo

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:

InputResultWhy it matters
Ann.Lee+billing@Example.COMacceptPlus tags and mixed case are fine
o'brien@example.comacceptApostrophes are allowed in the local part
ann@xn--bcher-kva.deacceptInternationalised domains work in their ASCII (A-label) form
ann@bücher.derejectThe same domain in Unicode form does not
josé@example.comrejectNo UTF-8 local parts (SMTPUTF8 addresses)
"ann lee"@example.comrejectRFC-valid quoted local parts are not accepted
a#b@example.com, a=b@...rejectOnly letters, digits and _ ' + - . in the local part
ann@[192.168.0.1]rejectNo IP address literals
ann@localhost, ann@examplerejectThe domain needs a dot and an alphabetic TLD
ann..lee@example.comrejectNo leading, trailing or consecutive dots
Ann Lee <ann@example.com>rejectDisplay-name form goes in name, not email
ann@gmial.comacceptWell-formed. Syntax cannot know it is a typo
ann@example.invalidacceptWell-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:

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:

AddressResult
ann@gmail.comok, via mx, five hosts
ann@example.comnull_mx, permanent
ann@thisdomaindoesnotexist-4471.comno_such_domain, permanent
ann@a91d7c22.mail.invalidno_such_domain, permanent
ann@bücher.deok as ann@xn--bcher-kva.de, via implicit_mx
ann@gmial.comok, 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:

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:

  1. Parse. Split display-name form into email and name before the checker sees it (the checker above returns display_name_form if you forget). Trim whitespace.
  2. 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.
  3. 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.
  4. 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_denied and reason_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.
  5. Send, with an Idempotency-Key derived from the business intent, so a retried tool call is not a second email.
  6. Watch the outcome. Handle 400 invalid_request by reading details.fields, handle 422 recipient_suppressed by removing the address, and record message.delivery_updated per 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

All guides · Documentation · Developers