Skip to content
Skip to main content

Restrict AI agent email recipients

Last updated:

An AI agent that generates and sends email will eventually try to email the wrong person. The model hallucinates an address, picks up an attacker-controlled recipient from a forwarded thread, or fans a reply out to a whole distribution list because the prompt said “reply all.” None of that is a Nylas problem to solve, because the API does exactly what your code tells it. The fix lives one layer up, in the validation you run before you ever call the send endpoint.

This recipe wires three guardrails around POST /v3/grants/{grant_id}/messages/send: an allowlist that limits recipient domains, a volume cap that stops a runaway loop, and a dry-run mode that lets the agent compose freely while you confirm every address it picks. All three run in your application, so they hold even when the model misbehaves.

How do I restrict an AI agent email to approved recipients?

Section titled “How do I restrict an AI agent email to approved recipients?”

Validate every recipient address against an allowlist in your own code before you call the send endpoint, and reject the whole request if any address fails. The agent never talks to the mail provider directly: it asks your service to send, and only a clean payload with approved to, cc, and bcc fields reaches POST /v3/grants/{grant_id}/messages/send.

The allowlist is the single most effective guardrail because it fails closed. An empty or unknown domain is denied by default, so a hallucinated address like [email protected] never leaves your process. Keep the list small: most production agents only ever need to reach 1 or 2 domains, your own and the customer’s. The check below normalizes each address, splits on the last @, and refuses anything outside the set before a single byte hits the network.

ALLOWED_DOMAINS = {"yourcompany.com", "customer.example"}
def assert_recipients_allowed(message):
recipients = message.get("to", []) + message.get("cc", []) + message.get("bcc", [])
for r in recipients:
addr = r["email"].strip().lower()
domain = addr.rsplit("@", 1)[-1]
if domain not in ALLOWED_DOMAINS:
raise PermissionError(f"recipient domain not allowed: {domain}")
return message

How do I enforce a recipient allowlist before an agent sends?

Section titled “How do I enforce a recipient allowlist before an agent sends?”

Treat the grant as a dumb pipe and put every policy in a send wrapper that the agent must call. There is no per-grant recipient policy stored inside the API, so you enforce rules at the application boundary: one function takes the agent’s proposed message, runs the allowlist, the volume cap, and the dry-run gate, then forwards an approved payload.

Centralizing the rules in one wrapper means the agent has exactly one way to send, and that path is the only place policy lives. Add an Idempotency-Key header, a unique key up to 256 characters, so a retried call never produces a duplicate send. The API caches each response for 1 hour per grant and returns Idempotent-Response: true on the replay. The wrapper below threads all three guardrails plus idempotency through a single send.

import uuid, requests
def guarded_send(grant_id, message, api_key, dry_run=False):
assert_recipients_allowed(message)
assert_under_send_cap(grant_id)
if dry_run:
return {"status": "dry_run", "would_send_to": message["to"]}
resp = requests.post(
f"https://api.us.nylas.com/v3/grants/{grant_id}/messages/send",
headers={
"Authorization": f"Bearer {api_key}",
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
},
json=message,
)
resp.raise_for_status()
return resp.json()

How can I stop an AI email agent from emailing the wrong people?

Section titled “How can I stop an AI email agent from emailing the wrong people?”

Run the agent in dry-run mode first, so it composes the full message and resolves every recipient, but your wrapper returns the intended to list instead of sending. You inspect those addresses, confirm they match the allowlist, then flip dry_run to False. This catches the failure allowlists miss: a correct domain on the wrong person.

Dry-run is your staging gate. Before any agent reaches production, log at least 14 days of dry-run sends and audit the recipient list by hand. If the agent would have emailed the wrong person even once in that window, it is not ready. Pair the dry run with the volume cap below, because a wrong recipient and a runaway loop are different failures: one targets the wrong inbox, the other floods every inbox. A starting cap of 50 sends per hour and 200 per day per mailbox sits well under provider ceilings, so a bug trips your limit long before it trips theirs.

import time
WINDOW_SECONDS = 3600
MAX_PER_WINDOW = 50
_send_log = {} # grant_id -> list of unix timestamps
def assert_under_send_cap(grant_id):
now = time.time()
recent = [t for t in _send_log.get(grant_id, []) if now - t < WINDOW_SECONDS]
if len(recent) >= MAX_PER_WINDOW:
raise RuntimeError(f"send cap reached: {len(recent)} in last hour")
recent.append(now)
_send_log[grant_id] = recent

Allowlist domains vs other recipient controls

Section titled “Allowlist domains vs other recipient controls”

Domain allowlisting is the right default, but it is not the only control, and each option trades coverage for precision. The table compares the common approaches you can layer on the send wrapper. The unified Nylas wrapper column shows where each rule runs in the flow described above, all in your own code ahead of the send call.

ControlNative provider settingUnified Nylas wrapper
Limit recipient domainsPer-provider admin policy, hard to apply per agentAllowlist set checked before POST /v3/grants/{grant_id}/messages/send
Exact-address allowlistManual mailbox rules, no APISwap the domain set for a full-address set in the same check
Cap send volumeProvider daily limits only, account-widePer-grant rolling counter, 50/hour by default
Preview before sendNot availableDry-run mode returns the recipient list, sends nothing
Anchor reply recipientsNoneSet recipients from the source thread, not the model

A real tradeoff: if your agent legitimately needs to reach arbitrary external recipients, for example a sales tool that emails any prospect, a static allowlist will block valid sends and frustrate the workflow. In that case drop the domain check and lean harder on the volume cap, a human-approval step, and per-recipient anomaly detection instead. Allowlisting fits internal and known-customer agents, not open-ended outreach.

Validate recipients against the source thread

Section titled “Validate recipients against the source thread”

When an agent replies to a thread, the safest recipient list comes from the original message, not from the model. Pull the source message with GET /v3/grants/{grant_id}/messages, read its from and reply_to fields, and set those as the only allowed recipients for the reply. This closes the gap where a prompt-injected instruction inside the email body convinces the agent to add a new address.

Reply sends should carry reply_to_message_id so the message threads correctly and the recipients stay anchored to the conversation. The agent proposes a body; your wrapper sets the recipients from the fetched thread, not from the model output. This pattern matters most for inbound automation, where the message content is untrusted by definition. For the full inbound reply flow, see the handle agent replies recipe, and for the wider guardrail set around autonomous sends, see build an autonomous email agent.