# Restrict AI agent email recipients

Source: https://developer.nylas.com/docs/cookbook/agents/restrict-agent-recipients/

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?

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 `ceo@acme-corp.example` 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.

```python
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?

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.

```python


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?

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.

```python


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

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

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](/docs/cookbook/agent-accounts/handle-replies/) recipe, and for the wider guardrail set around autonomous sends, see [build an autonomous email agent](/docs/cookbook/agents/autonomous-email-agent/).

## What's next

- [Build an autonomous email agent](/docs/cookbook/agents/autonomous-email-agent/) for the full guardrail set: rate caps, kill switch, and audit log
- [Handle agent replies](/docs/cookbook/agent-accounts/handle-replies/) for anchoring reply recipients to the source thread
- [List email messages](/docs/cookbook/email/messages/list-messages-microsoft/) to fetch a source thread before validating its recipients
- [Getting started with Nylas](/docs/v3/getting-started/) to create a project, connector, and your first grant