# Stop your agent emailing the wrong recipients

> Source: <https://dev.to/mqasimca/stop-your-agent-emailing-the-wrong-recipients-387e>
> Published: 2026-06-29 17:56:30+00:00

Here's the failure mode that keeps me up at night about autonomous email agents: not the agent that goes silent, but the one that sends. A model fat-fingers a recipient. It picks up `qa@internal-staging.example`

from a config file it shouldn't have read. It replies-all to a forwarded thread that quietly carried a BCC to a competitor. Your send wrapper looks clean, your tests pass, and one afternoon the agent emails a customer's pricing to the wrong domain. By the time you see it in the logs, the message is on someone else's mail server and you can't recall it.

Most teams handle this with an allowlist in application code — validate the recipients, reject the bad ones, then call send. That's the right first move and you should absolutely do it. But it has a gap: it only protects the send path *you* wrote. The moment a second code path calls the API directly, or a retry skips the wrapper, or a teammate ships a new endpoint that forgets the check, the guardrail isn't there. The address lives in your process, and processes have bugs.

There's a lower layer that doesn't. An Agent Account on Nylas can evaluate an **outbound Rule** at send time — *after* your code hands the message off, but *before* Nylas hands it to the email provider. If the rule matches a denied recipient, the send is rejected with a `403`

and no message leaves the building. This post is about that send-side block, used as **data-loss prevention**: a server-side backstop that catches the wrong recipient even when your application code doesn't.

The cookbook recipe on [restricting agent recipients](https://developer.nylas.com/docs/cookbook/agents/restrict-agent-recipients/) makes the case for the application-layer allowlist, and it's a good case. Validate recipients in a `guarded_send`

wrapper, fail closed on unknown domains, run a dry-run gate. Do that. This post is not an argument against it.

It's an argument for a *second* layer, because the two fail in different places:

`recipient.*`

rule fields match against `to`

still trips the block. That's exactly the DLP case you want: the recipient you didn't think to look at.`in_list`

— Nylas blocks the send anyway rather than letting it through. More on that contract below, because it changes the status code you get back.The honest framing: this is a guardrail Nylas enforces, and it *complements* your app-side checks — it doesn't replace them. Defense in depth. Your wrapper is the fail-closed allowlist you own; the outbound Rule is the server-side denylist that holds when the wrapper doesn't.

Worth saying up front, because it's the reassuring part: an Agent Account is just a **grant** with a `grant_id`

. Everything you already know about Messages, Drafts, Threads, and `POST /v3/grants/{grant_id}/messages/send`

is unchanged. The agent still composes and sends exactly the same way.

Rules and Lists sit one level up, on the *control plane*. They're application-scoped admin resources — no grant ID in the path, your API key identifies the application — and they apply to every Agent Account in a workspace. So there's nothing new to learn on the data plane. You're adding a checkpoint the send passes through, not rewriting how the send works.

The pieces form a short chain. A **List** holds the denied recipient values. A **Rule** with `trigger: outbound`

references that list through the `in_list`

operator and says "if a recipient is on this list, `block`

." A **workspace** carries the rule in its `rule_ids`

array, and every Agent Account in the workspace inherits it. Miss the last step and nothing fires — I'll come back to that, because it's the most common mistake.

You need an Agent Account — a grant created via `POST /v3/connect/custom`

against a registered domain, covered in [Agent Accounts](https://developer.nylas.com/docs/v3/agent-accounts/) — plus an API key for the same application. The host in every example is `https://api.us.nylas.com`

, and every call carries `Authorization: Bearer <NYLAS_API_KEY>`

.

I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for. Three subcommands cover the whole flow and they don't overlap: `nylas agent list`

manages the denylist (`/v3/lists`

), `nylas agent rule`

manages the rule (`/v3/rules`

), and `nylas workspace update`

arms the rule by adding it to a workspace's `rule_ids`

. CLI reference lives at [cli.nylas.com/docs/commands](https://cli.nylas.com/docs/commands).

One asymmetry to internalize, because it's the thing people get wrong: **only outbound rules can match recipients.** Inbound rules see `from.*`

and nothing else — they only know who sent the mail. Outbound rules add `recipient.address`

, `recipient.domain`

, `recipient.tld`

, and `outbound.type`

. Send-side recipient blocking is an outbound-trigger job, full stop. If you try to express it as an inbound rule, the field isn't even available.

Start with the List that holds the denied recipient domains. A list has a fixed `type`

— `domain`

, `tld`

, or `address`

— set at creation and immutable. The type decides which rule fields it can match: a `domain`

list matches `recipient.domain`

(and `from.domain`

), an `address`

list matches the `.address`

fields. For DLP, domain-level is the right granularity most of the time — you want to block an entire competitor or test domain, not enumerate addresses.

```
curl --request POST \
  --url "https://api.us.nylas.com/v3/lists" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Denied recipient domains",
    "type": "domain"
  }'
```

The response carries the `id`

you'll reference from the rule:

```
{
  "request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88",
  "data": {
    "id": "d1e2f3a4-5678-4abc-9def-0123456789ab",
    "name": "Denied recipient domains",
    "type": "domain",
    "items_count": 0,
    "created_at": 1742932766,
    "updated_at": 1742932766
  }
}
```

The CLI does the same and can seed items at creation with repeatable `--item`

flags:

```
nylas agent list create \
  --name "Denied recipient domains" \
  --type domain \
  --item competitor.example \
  --item internal-staging.example
```

The values you'd put here are the ones an agent should never reach in production: competitor domains, test and staging domains that exist in your environment and could leak into a recipient field, and any address class you've decided is off-limits. Values are lowercased, trimmed, and validated against the list's type, so a `domain`

list rejects a full email address — pick the type to fit what you'll deny.

This is where the dynamic part lives. Add up to 1000 items per request; duplicates are silently ignored, so you can re-run an add idempotently.

```
curl --request POST \
  --url "https://api.us.nylas.com/v3/lists/<LIST_ID>/items" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "items": ["competitor.example", "internal-staging.example"]
  }'
```

From the CLI, items are positional arguments after the list ID:

```
nylas agent list add <LIST_ID> competitor.example internal-staging.example
```

This is the command you hand to a non-engineer, wire into an internal admin panel, or call from an incident runbook the moment you realize a domain needs blocking. It's a single API write that immediately changes the behavior of the rule pointing at the list — no rule edit, no deploy. To pull a domain back out, `DELETE /v3/lists/{list_id}/items`

(or `nylas agent list remove <LIST_ID> competitor.example`

).

Now the rule that turns the list into a send-side block. The trigger is `outbound`

, the condition matches `recipient.domain`

against the denylist with `in_list`

, and the single action is `block`

. Note that `value`

for an `in_list`

condition is an **array of list IDs**, not the domains themselves — the domains live *in* the list; the rule references the list by ID. A single `in_list`

condition can reference up to 10 lists.

```
curl --request POST \
  --url "https://api.us.nylas.com/v3/rules" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Block sends to denied recipients",
    "priority": 1,
    "trigger": "outbound",
    "match": {
      "conditions": [
        {
          "field": "recipient.domain",
          "operator": "in_list",
          "value": ["<LIST_ID>"]
        }
      ]
    },
    "actions": [
      { "type": "block" }
    ]
  }'
```

The CLI condition format is `field,operator,value`

, and for `in_list`

the trailing comma-separated values are list IDs — `field,in_list,list-id-1,list-id-2`

— which maps directly to the array in the JSON above:

```
nylas agent rule create \
  --name "Block sends to denied recipients" \
  --trigger outbound \
  --priority 1 \
  --condition recipient.domain,in_list,<LIST_ID> \
  --action block
```

A few things that matter about this rule. The `block`

action is **terminal** — it can't be combined with other actions, because there's nothing to do to a message you're refusing to send. For an outbound rule it rejects the send before the message reaches the provider, so no sent copy is stored. And `recipient.domain`

matches against *any* recipient, including CC, BCC, and the SMTP envelope — which is the whole DLP point. A denied domain hidden in a BCC the agent shouldn't have added still trips the block. `is_not`

would only be true when *no* recipient matches, but for a denylist you want `in_list`

, which fires when any recipient is on the list.

If you'd rather block a single specific address than a whole domain, make the List `type: address`

and match `recipient.address`

instead — same shape, finer granularity.

Here's the step that trips everyone up: creating the List and the Rule isn't enough. **A rule is inert until a workspace references it.** Workspaces carry rules — you don't attach a rule to a grant directly. You add the rule's ID to a workspace's `rule_ids`

array, and from then on it runs for every Agent Account in that workspace.

Attach it with `PATCH /v3/workspaces/{workspace_id}`

. The array is a *full replacement*, not an append, so pass every rule ID you want active — include any existing rules you don't want to silently detach:

```
curl --request PATCH \
  --url "https://api.us.nylas.com/v3/workspaces/<WORKSPACE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "rule_ids": ["<OUTBOUND_RULE_ID>"]
  }'
```

From the CLI, `nylas workspace update`

takes a comma-separated list of rule IDs:

```
nylas workspace update <WORKSPACE_ID> --rules-ids <OUTBOUND_RULE_ID>
```

If you don't know the workspace ID, the default workspace holds any Agent Account you haven't placed in a custom one, so arming there covers your unassigned accounts. As a convenience, `nylas agent rule create`

resolves the default grant's workspace and attaches the new rule to it for you — but when you create rules over the raw API, or you want the rule on a *non-default* workspace, this `PATCH`

is the step that actually turns the block on. The same `rule_ids`

array carries inbound and outbound rules together; Nylas filters by `trigger`

at evaluation time, so an outbound rule never runs on received mail.

This is the part worth seeing, because the rejection has a specific contract your code needs to handle. With the rule armed, ask the Agent Account to send to a denied domain. The data-plane call is the ordinary send — nothing special:

```
curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "to": [{ "email": "deals@competitor.example" }],
    "subject": "Q3 pricing",
    "body": "Here is the proposal you asked about."
  }'
```

The equivalent CLI send, which is what I reach for to reproduce a block quickly:

```
nylas email send <NYLAS_GRANT_ID> \
  --to deals@competitor.example \
  --subject "Q3 pricing" \
  --body "Here is the proposal you asked about."
```

Because `competitor.example`

is on the denylist, the outbound rule matches and the send is rejected with **HTTP 403** before it reaches the provider. No message leaves Nylas, and no sent copy is stored:

```
{
  "request_id": "f0a1b2c3-d4e5-46f7-89ab-cdef01234567",
  "error": {
    "type": "provider_error",
    "message": "Message blocked by an outbound rule."
  }
}
```

Your application should treat a `403`

here exactly like any other non-retryable delivery failure: the send never reached the provider, there's no retry path that will deliver it, and re-sending the same payload will just be blocked again. Don't loop on it — surface it, log it, and move on.

There's one important wrinkle in the status code, and it's the fail-closed behavior I flagged earlier. A genuine rule match returns `403`

. But if the rule engine *can't evaluate* the block because of a transient infrastructure error — for example, the `in_list`

lookup fails mid-evaluation — Nylas blocks the send anyway and returns ** 503** instead. The distinction matters for your retry logic: a

`403`

is a permanent policy block (don't retry), while a `503`

is a retryable tempfail (the send might succeed on a later attempt once the transient error clears). Branch on the status code, not just on "the send failed."When a send is blocked and you want to know exactly why — which is most useful the first time you wire this up and the *one* time it blocks something it shouldn't have — pull the rule-evaluation audit log for the grant:

```
curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/rule-evaluations?limit=50" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
```

Each record carries the evaluation stage — `outbound_send`

for a send-time check — the normalized recipient data that was considered (`recipient_addresses`

, `recipient_domains`

, `recipient_tlds`

, `outbound_type`

), the IDs of any matched rules, and the actions applied. For a blocked send, `message_id`

is `null`

because no sent copy was stored. And if the block came from a fail-closed evaluation error rather than a genuine match, the record sets `blocked_by_evaluation_error: true`

— that's how you tell a real denylist hit (`403`

) from an infrastructure tempfail (`503`

) after the fact. Cross-reference `matched_rule_ids`

with the Rules API to see exactly which condition tripped.

A few things I've watched people trip over with send-side blocks specifically:

`rule_ids`

. Creating the List and Rule is two-thirds of the job; the `PATCH /v3/workspaces/{workspace_id}`

is what arms it.`rule_ids`

is a full replacement.`value`

is an array of list IDs for `in_list`

, not the domains.`domain`

list works with `recipient.domain`

. Point a `recipient.address`

condition at a domain list and it won't match — and you'll think the block is broken when it's a type mismatch.`recipient.*`

covers hidden recipients on purpose.`403`

is permanent, `503`

is retryable.`403`

policy block will never deliver; a `503`

fail-closed tempfail might on retry. Treat them differently or you'll either hammer a permanent block or give up on a transient one.The shape to remember: a **List** holds the denied recipients, an **outbound Rule** with `recipient.domain in_list`

plus a `block`

action rejects the send, and the **workspace** `rule_ids`

array arms it. Create the list, create the rule, attach the rule — skip that last step and the agent sends to the competitor anyway. Once it clicks, you've got a server-side DLP block that holds even when the model, and your own code, don't.

`403`

/`503`

fail-closed contract`nylas agent list`

, `nylas agent rule`

, and `nylas workspace update`

reference
