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 messageHow 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 = 3600MAX_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] = recentAllowlist 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.
| Control | Native provider setting | Unified Nylas wrapper |
|---|---|---|
| Limit recipient domains | Per-provider admin policy, hard to apply per agent | Allowlist set checked before POST /v3/grants/{grant_id}/messages/send |
| Exact-address allowlist | Manual mailbox rules, no API | Swap the domain set for a full-address set in the same check |
| Cap send volume | Provider daily limits only, account-wide | Per-grant rolling counter, 50/hour by default |
| Preview before send | Not available | Dry-run mode returns the recipient list, sends nothing |
| Anchor reply recipients | None | Set 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.
What’s next
Section titled “What’s next”- Build an autonomous email agent for the full guardrail set: rate caps, kill switch, and audit log
- Handle agent replies for anchoring reply recipients to the source thread
- List email messages to fetch a source thread before validating its recipients
- Getting started with Nylas to create a project, connector, and your first grant