Controls · September 28, 2026
AI Agent Hallucinated an Email Address? Fix the Source
An AI agent's hallucinated email address either bounces or reaches a stranger. Take recipients from records, not model output, and gate the rest.
The agent was asked to send the signed proposal to Dana at Northwind. It wrote
dana.whitfield@northwind.com. The real address is dwhitfield@northwind.co. The
message was accepted, nothing errored, and the log reads like a success. Whether it
bounced back or landed in a stranger’s inbox depends on facts about someone else’s mail
server that neither you nor the model can see from here. That is what an AI agent’s
hallucinated email address looks like in practice: plausible, well-formed and wrong.
This article gives you a resolution order that takes the luck out: every recipient comes from a system of record, never from model output, and any address that cannot be traced to one is held for a person. If your problem is the narrower one of fencing an agent into a set of domains, restricting which domains an agent can email covers the allowlist mechanics in full.
An AI agent hallucinated email address: bounce or stranger
An invented address falls into one of four cases, and only one of them tells you it happened.
| What the model produced | What the receiving side does | What you see | Who read it |
|---|---|---|---|
| A mailbox that does not exist, real domain | Rejects the recipient | message.delivery_updated with status bounced | Nobody |
| A domain with no mail service | Nothing to deliver to | A bounced or failed recipient status, later | Nobody |
| Any mailbox at a catch-all domain | Accepts everything for the domain | delivered | Whoever reads the catch-all |
| A real mailbox belonging to someone else | Delivers normally | delivered | A stranger |
The first two rows are the lucky ones, and they are also the only ones you would notice.
The last two produce exactly the same record as a correct send: provider acceptance,
then a message.delivery_updated event whose payload carries the message_id, the
recipient address and a status of delivered. The catch-all accepted the message
and a real stranger received the other one, and no field distinguishes either from
“delivered to Dana”. What each of those statuses does and does
not prove is set out in accepted vs delivered; the
point here is that none of them proves the recipient was the intended one.
The lucky case is not free either. A live recipient that was sent to used one email unit
whether it bounced or not, and a permanent bounce adds the address to your workspace’s
suppression list. Only a provider-side suppressed outcome reverses the unit.
Invented addresses tend to come in recognisable shapes, which is worth knowing because none of them fails a syntax check:
- The pattern guess. The model knows the person’s name and the company domain and
assembles
first.last@, when the company usesflast@orfirst@. - The TLD drift.
northwind.comfornorthwind.co, or the reverse. The wrong one is often a real, registered domain belonging to someone else. - The stale address. An address that was correct in some document the model saw, for a person who has since moved companies.
- The conflation. Two contacts in the context window, one person’s name attached to the other person’s address.
Why the agent cannot check its own recipients
The model cannot validate an address it generated, because the same process that produced the error evaluates the fix. Asking the agent to “double-check the recipient” produces a second confident answer, not a check. There is no ground truth in the context window to compare against, or it would have used it.
Mechanical validation outside the model helps less than it looks. A syntax check passes every row in the table above. A domain check passes three of the four. Even a mailbox existence probe removes only the first two rows, says yes to the catch-all, and confirms the stranger’s mailbox is real. Validation answers “can this address receive mail?”, which is a different question from “is this the person I meant?”. The syntax and DNS checks worth running, and why the probe is not, are set out in validating an address before your agent sends; none of them touches the dangerous rows.
The server-side policy has the same limit. Every rule the platform evaluates is about whether a recipient is allowed, never about whether it is intended: the suppression list, the block list, the allow list, the daily recipient cap. A well-formed, unsuppressed, allowlisted wrong address passes all of them. That is not a gap in this product specifically; intent lives in your data, not in the mail system. So the check has to be built from your data, and it has to run outside the model.
Where a recipient address is allowed to come from
Rank the possible sources and refuse anything below the line.
- The thread’s own headers. For a reply, the right recipient is already stored: the
inbound message’s
fromaddress, or itsreply_toif the sender set one. These come from the message itself, not from anything the model wrote. - A system-of-record lookup by ID. For a new message, the model names a contact by an identifier your CRM, ticketing system or user table understands, and your code resolves the address. The model never types the address at all.
- A literal a human supplied. If the operator’s instruction contains the address verbatim, your code can copy that string. Copy it from the instruction, not from the model’s restatement of it.
- Model output. Never sent directly. An address that exists only because the model produced it goes to a human, every time.
Two of those deserve a caveat.
A threaded reply does not pick its own recipients. Passing reply_to_message_id
keeps the reply in the parent’s thread, but the product does not derive to from the
parent. The MCP schema’s description of reply_to_message_id says to “supply explicit
recipients and subject”, and to is required on every send, REST or MCP. An agent replying in exactly the right thread
can still address the reply to an invented person, and the thread linkage will make the
mistake look more legitimate, not less. Your code must fill in to from the parent,
rather than trusting the model to copy it across.
Reply-To is the sender’s request, not proof of identity. A legitimate sender uses
it to route replies to a shared mailbox. A hostile one uses it to redirect your agent’s
answer somewhere else. Honour it when its domain matches the from domain or a domain
you already correspond with, and treat a mismatch as a recipient you have not seen
before, which the next section sends to review. How hostile mail tries to steer an
agent’s actions is covered in the agent email threat model.
Resolving reply recipients from the thread, in code
The thread messages endpoint returns each message with its direction, from,
reply_to, to and cc, which is everything you need to answer both “who should this
reply go to?” and “has this address already been vouched for?”.
const API = "https://api.emailforagents.ai/v1";
type Addr = { email: string; name?: string };
type Msg = {
id: string;
direction: "inbound" | "outbound";
subject: string;
from: Addr;
reply_to: Addr[];
to: Addr[];
cc: Addr[];
};
async function threadMessages(project: string, inbox: string, thread: string, key: string) {
const all: Msg[] = [];
let cursor: string | undefined;
do {
const url = new URL(`${API}/projects/${project}/inboxes/${inbox}/threads/${thread}/messages`);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (!res.ok) throw new Error(`thread read failed: ${res.status}`);
const page = await res.json();
all.push(...page.data);
cursor = page.has_more ? page.next_cursor : undefined;
} while (cursor);
return all;
}
const norm = (e: string) => e.trim().toLowerCase();
const domainOf = (e: string) => norm(e).split("@").pop()!;
// The only recipient a reply is allowed to have, taken from the parent's headers.
function replyRecipient(parent: Msg): string {
const from = norm(parent.from.email);
const replyTo = parent.reply_to[0] && norm(parent.reply_to[0].email);
if (replyTo && domainOf(replyTo) === domainOf(from)) return replyTo;
return from; // a cross-domain Reply-To is ignored here; honour it only via review
}
// Addresses already vouched for: senders who wrote in, same-domain Reply-To values,
// and recipients of our own earlier sends (which passed this gate when they went out).
// Cc and To on inbound mail are deliberately excluded: the sender chose those.
function knownAddresses(messages: Msg[], ownAddress: string): Set<string> {
const seen = new Set<string>();
for (const m of messages) {
if (m.direction === "inbound") {
seen.add(norm(m.from.email));
for (const r of m.reply_to) {
if (domainOf(r.email) === domainOf(m.from.email)) seen.add(norm(r.email));
}
} else {
for (const a of [...m.to, ...m.cc]) seen.add(norm(a.email));
}
}
seen.delete(norm(ownAddress));
return seen;
}
The exclusion in knownAddresses is deliberate. The to and cc lines of an inbound
message were written by whoever sent it, so counting them as vouched-for would let any
sender add addresses to your agent’s trusted set. A reply-all to someone the sender
copied in therefore goes to review the first time, and is known from then on.
Wire it into the tool you give the agent, not into its prompt. The agent’s reply tool
takes a message_id and a body. It does not take a to field at all, so there is
nothing for the model to invent:
async function agentReply(args: { thread_id: string; message_id: string; text: string }) {
const msgs = await threadMessages(PROJECT, INBOX, args.thread_id, READ_KEY);
const parent = msgs.find((m) => m.id === args.message_id && m.direction === "inbound");
if (!parent) throw new Error("reply target is not an inbound message in this thread");
return send(
{
to: [{ email: replyRecipient(parent) }],
subject: `Re: ${parent.subject}`, // also from the parent, not from the model
reply_to_message_id: parent.id,
text: args.text,
},
knownAddresses(msgs, INBOX_ADDRESS),
);
}
For new messages the pattern is the same with a different source: the tool takes a
contact_id, your code looks the address up, and an unknown ID is an error the agent
sees, not an address it can fill in.
The approval gate for recipients nobody has seen before
Some sends legitimately need an address the model found: a name pulled from a signature block, a colleague mentioned in the body (“please loop in our controller”). Those are exactly the sends that need a person. The rule is simple: if any recipient is not in the known set, the send goes to review, and your code decides that, not the model.
The product gives you two ways to make a send land in the approval queue, and it helps to know precisely what each one does.
A draft-only credential. A key holding drafts:submit but not messages:send
cannot transmit. Every send it makes is converted into a held draft and returns HTTP 202
with result: "approval_required", and an approval.requested event is emitted.
reason_codes is ["draft_only_principal"] unless a workspace, project or inbox
approval rule also fired, in which case it lists that rule instead. So keep two credentials behind your send
function: one that can send, used only when every recipient is known, and one that can
only submit drafts, used for everything else.
async function send(payload: { to: Addr[]; cc?: Addr[]; bcc?: Addr[]; [k: string]: unknown }, known: Set<string>) {
const all = [...payload.to, ...(payload.cc ?? []), ...(payload.bcc ?? [])].map((a) => norm(a.email));
const key = all.every((e) => known.has(e)) ? SEND_KEY : DRAFT_ONLY_KEY;
// POST .../inboxes/{inbox_id}/messages with an Idempotency-Key, as usual
return postMessage(payload, key);
}
Over MCP the equivalent is the grant. A connection granted read and draft, without send,
produces a session where send_message is not in the tool list at all, and
create_draft cannot transmit mail on any grant. An approval request that nobody acts
on expires after seven days.
An inbox rule. Setting approval_mode to "always" on the inbox policy holds every
send from that inbox, with reason always. It is the right choice for an inbox where
every message deserves a read, and the wrong one for an inbox where most replies go to
the person who just wrote in.
A third reason code, new_recipient_domain, appears in the vocabulary, and its name
suggests the check you want. Do not rely on it for that. In this build the rule
matches whenever it is present on a policy, without comparing the recipient’s domain to
anything the inbox has written to before. In effect, an inbox that carries it sends
every message to approval, the same as always. It also cannot be set through the
policy API, whose approval_mode field accepts only direct or always. First-time-recipient detection is
something you build from the thread and your records, as above.
Whatever triggers the hold, the reviewer’s job is specific: compare each recipient with where it came from. Show the proposed address next to the thread’s known addresses, or next to the CRM record it should match. A reviewer shown only the body will approve a well-written message to the wrong person. Human approval before an agent sends covers what else the review screen must show and how an approval binds to one exact draft version.
The allowlist as the hard boundary
Approval depends on a person noticing. An allowlist does not. If the agent should only
ever correspond with a known set of organisations, put them in the inbox policy with
PUT /v1/projects/{project_id}/inboxes/{inbox_id}/policy, as allow_domains or
allow_addresses. A send where any recipient in To, Cc or Bcc falls outside the list is
refused with 403 policy_denied and reason_codes: ["recipient_not_allowed"]. It never
reaches the approval queue, so no reviewer can be talked into it.
Three properties make it a real boundary rather than a suggestion. The policy can only be
changed by a human owner or admin, so an agent credential cannot widen its own list. A
domain rule matches the exact domain after the @, so northwind.com on the list does
not admit northwind.co or mail.northwind.com. And the policy is evaluated again when
the queued send is dispatched, not only when it is requested. The full rule order, and
how inherited workspace and project policies combine with the inbox’s own, is on the
sending controls reference.
The allowlist is also the only control in this article that stops the fourth row of the table outright, provided the stranger’s domain is not on it. It does nothing for a wrong person at an allowed domain, which is why it complements the resolution order rather than replacing it.
What suppression catches, and what it cannot
The product keeps a workspace suppression list. An address is added when its delivery
outcome is a permanent bounce, a complaint, or a provider-side suppression, and only when
the provider’s callback identifies that one recipient. After that, a live send that
includes the address is refused with 422 recipient_suppressed, marked non-retryable.
The check runs again when a queued send is dispatched, so a held draft approved later is
caught too.
For hallucinated addresses that means one useful thing: an invented address that
bounced once will not bounce twice. If your agent keeps regenerating the same wrong
guess, the second attempt fails loudly at request time instead of silently at the
recipient’s server. Treat recipient_suppressed as a signal that the address came from
somewhere untrustworthy, and fix the source rather than the address.
It cannot do more than that, and it would be wrong to lean on it:
- It only learns from the first failure. The first send to any invented address still goes out.
- It only learns from bounces. A delivered message to a stranger is a success as far as the list is concerned, so the dangerous rows of the table never reach it.
- It is keyed on the exact address. A new variant of the same wrong guess is a new address.
Watch message.delivery_updated for bounced outcomes on recipients your code marked as
model-sourced. A bounce on an address that came from a thread header or a CRM record is a
data problem; a bounce on one the model produced is this failure mode, and a count of
them tells you how often the gate is doing real work.
A checklist before the agent sends anything
Run through this for any agent with a send path, and prove it against a test inbox first, where nothing can reach an internet recipient.
- Reply tools take a message ID, and
tois derived from that message’sfromor a same-domainreply_to. The model cannot supply recipients for a reply. - New-message tools take a contact ID, resolved by your code against a system of record.
- Any recipient not in the thread’s known set, or not resolved from a record, is sent
with a draft-only credential, so it lands in approval with
draft_only_principal. - The approval screen shows each recipient beside its source.
- The inbox policy has an allowlist if the agent’s correspondents are a bounded set.
new_recipient_domainis not part of your design, until it actually compares domains.recipient_suppressedand model-sourced bounces are alerted on, not retried.
How the allowlist, approval queue and daily cap fit together in the console is on the controls overview. The order above is the part only you can build, because the only record of who the agent is meant to write to lives in your systems, not in the mail system.
Keep reading
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.
Human Approval Before an AI Agent Sends Email
When to require review, why approval binds to one exact draft version, how recipient rules and daily caps work, and what editing a draft does to its approval.
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.