cd /news/ai-agents/stop-your-agent-emailing-the-wrong-r… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-43827] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=↑ positive

Stop your agent emailing the wrong recipients

Nylas introduces an outbound Rule feature for its Agent Accounts that acts as a server-side data-loss prevention layer, blocking emails to denied recipients even when application-level checks fail. The rule evaluates recipients at send time and rejects the message with a 403 status if a match is found, providing a defense-in-depth approach to prevent accidental email leaks.

read12 min views1 publishedJun 29, 2026

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 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 β€” 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.

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 contractnylas agent list

, nylas agent rule

, and nylas workspace update

reference

── more in #ai-agents 4 stories Β· sorted by recency
── more on @nylas 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/stop-your-agent-emai…] indexed:0 read:12min 2026-06-29 Β· β€”