{"slug": "stop-your-agent-emailing-the-wrong-recipients", "title": "Stop your agent emailing the wrong recipients", "summary": "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.", "body_md": "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`\n\nfrom 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.\n\nMost 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.\n\nThere'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`\n\nand 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.\n\nThe 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`\n\nwrapper, fail closed on unknown domains, run a dry-run gate. Do that. This post is not an argument against it.\n\nIt's an argument for a *second* layer, because the two fail in different places:\n\n`recipient.*`\n\nrule fields match against `to`\n\nstill trips the block. That's exactly the DLP case you want: the recipient you didn't think to look at.`in_list`\n\n— 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.\n\nWorth saying up front, because it's the reassuring part: an Agent Account is just a **grant** with a `grant_id`\n\n. Everything you already know about Messages, Drafts, Threads, and `POST /v3/grants/{grant_id}/messages/send`\n\nis unchanged. The agent still composes and sends exactly the same way.\n\nRules 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.\n\nThe pieces form a short chain. A **List** holds the denied recipient values. A **Rule** with `trigger: outbound`\n\nreferences that list through the `in_list`\n\noperator and says \"if a recipient is on this list, `block`\n\n.\" A **workspace** carries the rule in its `rule_ids`\n\narray, 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.\n\nYou need an Agent Account — a grant created via `POST /v3/connect/custom`\n\nagainst 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`\n\n, and every call carries `Authorization: Bearer <NYLAS_API_KEY>`\n\n.\n\nI 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`\n\nmanages the denylist (`/v3/lists`\n\n), `nylas agent rule`\n\nmanages the rule (`/v3/rules`\n\n), and `nylas workspace update`\n\narms the rule by adding it to a workspace's `rule_ids`\n\n. CLI reference lives at [cli.nylas.com/docs/commands](https://cli.nylas.com/docs/commands).\n\nOne asymmetry to internalize, because it's the thing people get wrong: **only outbound rules can match recipients.** Inbound rules see `from.*`\n\nand nothing else — they only know who sent the mail. Outbound rules add `recipient.address`\n\n, `recipient.domain`\n\n, `recipient.tld`\n\n, and `outbound.type`\n\n. 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.\n\nStart with the List that holds the denied recipient domains. A list has a fixed `type`\n\n— `domain`\n\n, `tld`\n\n, or `address`\n\n— set at creation and immutable. The type decides which rule fields it can match: a `domain`\n\nlist matches `recipient.domain`\n\n(and `from.domain`\n\n), an `address`\n\nlist matches the `.address`\n\nfields. 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.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/lists\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"name\": \"Denied recipient domains\",\n    \"type\": \"domain\"\n  }'\n```\n\nThe response carries the `id`\n\nyou'll reference from the rule:\n\n```\n{\n  \"request_id\": \"5fa64c92-e840-4357-86b9-2aa364d35b88\",\n  \"data\": {\n    \"id\": \"d1e2f3a4-5678-4abc-9def-0123456789ab\",\n    \"name\": \"Denied recipient domains\",\n    \"type\": \"domain\",\n    \"items_count\": 0,\n    \"created_at\": 1742932766,\n    \"updated_at\": 1742932766\n  }\n}\n```\n\nThe CLI does the same and can seed items at creation with repeatable `--item`\n\nflags:\n\n```\nnylas agent list create \\\n  --name \"Denied recipient domains\" \\\n  --type domain \\\n  --item competitor.example \\\n  --item internal-staging.example\n```\n\nThe 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`\n\nlist rejects a full email address — pick the type to fit what you'll deny.\n\nThis 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.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/lists/<LIST_ID>/items\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"items\": [\"competitor.example\", \"internal-staging.example\"]\n  }'\n```\n\nFrom the CLI, items are positional arguments after the list ID:\n\n```\nnylas agent list add <LIST_ID> competitor.example internal-staging.example\n```\n\nThis 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`\n\n(or `nylas agent list remove <LIST_ID> competitor.example`\n\n).\n\nNow the rule that turns the list into a send-side block. The trigger is `outbound`\n\n, the condition matches `recipient.domain`\n\nagainst the denylist with `in_list`\n\n, and the single action is `block`\n\n. Note that `value`\n\nfor an `in_list`\n\ncondition 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`\n\ncondition can reference up to 10 lists.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/rules\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"name\": \"Block sends to denied recipients\",\n    \"priority\": 1,\n    \"trigger\": \"outbound\",\n    \"match\": {\n      \"conditions\": [\n        {\n          \"field\": \"recipient.domain\",\n          \"operator\": \"in_list\",\n          \"value\": [\"<LIST_ID>\"]\n        }\n      ]\n    },\n    \"actions\": [\n      { \"type\": \"block\" }\n    ]\n  }'\n```\n\nThe CLI condition format is `field,operator,value`\n\n, and for `in_list`\n\nthe trailing comma-separated values are list IDs — `field,in_list,list-id-1,list-id-2`\n\n— which maps directly to the array in the JSON above:\n\n```\nnylas agent rule create \\\n  --name \"Block sends to denied recipients\" \\\n  --trigger outbound \\\n  --priority 1 \\\n  --condition recipient.domain,in_list,<LIST_ID> \\\n  --action block\n```\n\nA few things that matter about this rule. The `block`\n\naction 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`\n\nmatches 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`\n\nwould only be true when *no* recipient matches, but for a denylist you want `in_list`\n\n, which fires when any recipient is on the list.\n\nIf you'd rather block a single specific address than a whole domain, make the List `type: address`\n\nand match `recipient.address`\n\ninstead — same shape, finer granularity.\n\nHere'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`\n\narray, and from then on it runs for every Agent Account in that workspace.\n\nAttach it with `PATCH /v3/workspaces/{workspace_id}`\n\n. 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:\n\n```\ncurl --request PATCH \\\n  --url \"https://api.us.nylas.com/v3/workspaces/<WORKSPACE_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"rule_ids\": [\"<OUTBOUND_RULE_ID>\"]\n  }'\n```\n\nFrom the CLI, `nylas workspace update`\n\ntakes a comma-separated list of rule IDs:\n\n```\nnylas workspace update <WORKSPACE_ID> --rules-ids <OUTBOUND_RULE_ID>\n```\n\nIf 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`\n\nresolves 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`\n\nis the step that actually turns the block on. The same `rule_ids`\n\narray carries inbound and outbound rules together; Nylas filters by `trigger`\n\nat evaluation time, so an outbound rule never runs on received mail.\n\nThis 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:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"to\": [{ \"email\": \"deals@competitor.example\" }],\n    \"subject\": \"Q3 pricing\",\n    \"body\": \"Here is the proposal you asked about.\"\n  }'\n```\n\nThe equivalent CLI send, which is what I reach for to reproduce a block quickly:\n\n```\nnylas email send <NYLAS_GRANT_ID> \\\n  --to deals@competitor.example \\\n  --subject \"Q3 pricing\" \\\n  --body \"Here is the proposal you asked about.\"\n```\n\nBecause `competitor.example`\n\nis 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:\n\n```\n{\n  \"request_id\": \"f0a1b2c3-d4e5-46f7-89ab-cdef01234567\",\n  \"error\": {\n    \"type\": \"provider_error\",\n    \"message\": \"Message blocked by an outbound rule.\"\n  }\n}\n```\n\nYour application should treat a `403`\n\nhere 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.\n\nThere's one important wrinkle in the status code, and it's the fail-closed behavior I flagged earlier. A genuine rule match returns `403`\n\n. But if the rule engine *can't evaluate* the block because of a transient infrastructure error — for example, the `in_list`\n\nlookup fails mid-evaluation — Nylas blocks the send anyway and returns ** 503** instead. The distinction matters for your retry logic: a\n\n`403`\n\nis a permanent policy block (don't retry), while a `503`\n\nis 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:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/rule-evaluations?limit=50\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nEach record carries the evaluation stage — `outbound_send`\n\nfor a send-time check — the normalized recipient data that was considered (`recipient_addresses`\n\n, `recipient_domains`\n\n, `recipient_tlds`\n\n, `outbound_type`\n\n), the IDs of any matched rules, and the actions applied. For a blocked send, `message_id`\n\nis `null`\n\nbecause 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`\n\n— that's how you tell a real denylist hit (`403`\n\n) from an infrastructure tempfail (`503`\n\n) after the fact. Cross-reference `matched_rule_ids`\n\nwith the Rules API to see exactly which condition tripped.\n\nA few things I've watched people trip over with send-side blocks specifically:\n\n`rule_ids`\n\n. Creating the List and Rule is two-thirds of the job; the `PATCH /v3/workspaces/{workspace_id}`\n\nis what arms it.`rule_ids`\n\nis a full replacement.`value`\n\nis an array of list IDs for `in_list`\n\n, not the domains.`domain`\n\nlist works with `recipient.domain`\n\n. Point a `recipient.address`\n\ncondition at a domain list and it won't match — and you'll think the block is broken when it's a type mismatch.`recipient.*`\n\ncovers hidden recipients on purpose.`403`\n\nis permanent, `503`\n\nis retryable.`403`\n\npolicy block will never deliver; a `503`\n\nfail-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`\n\nplus a `block`\n\naction rejects the send, and the **workspace** `rule_ids`\n\narray 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.\n\n`403`\n\n/`503`\n\nfail-closed contract`nylas agent list`\n\n, `nylas agent rule`\n\n, and `nylas workspace update`\n\nreference", "url": "https://wpnews.pro/news/stop-your-agent-emailing-the-wrong-recipients", "canonical_source": "https://dev.to/mqasimca/stop-your-agent-emailing-the-wrong-recipients-387e", "published_at": "2026-06-29 17:56:30+00:00", "updated_at": "2026-06-29 18:19:00.257643+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-safety"], "entities": ["Nylas", "Agent Account", "Nylas API"], "alternates": {"html": "https://wpnews.pro/news/stop-your-agent-emailing-the-wrong-recipients", "markdown": "https://wpnews.pro/news/stop-your-agent-emailing-the-wrong-recipients.md", "text": "https://wpnews.pro/news/stop-your-agent-emailing-the-wrong-recipients.txt", "jsonld": "https://wpnews.pro/news/stop-your-agent-emailing-the-wrong-recipients.jsonld"}}