{"slug": "allowlist-and-denylist-rules-for-agent-email-backed-by-lists", "title": "Allowlist and denylist rules for agent email, backed by Lists", "summary": "Nylas introduced a dynamic allowlist and denylist system for AI email agents using Lists and Rules, decoupling policy from application code. Lists are editable collections of domains or addresses that Rules reference via the in_list operator, enabling non-engineers to update policies without redeployment. The system supports both inbound and outbound filtering, with a fail-closed approach on transient errors.", "body_md": "The usual way to keep an AI email agent from talking to the wrong people is a hardcoded set in your application code:\n\n```\nALLOWED_DOMAINS = {\"yourcompany.com\", \"customer.example\"}\n```\n\nThat works, and for a single internal agent it's the right call. But the moment a non-engineer needs to add a customer domain, or you want the *same* allowlist to govern five different agents, or you want it to apply to mail coming **in** and not just going **out**, that set in code becomes a deploy-shaped bottleneck. Every change is a PR, a review, a release.\n\nThere's a layer below your code that can carry that policy for you. An Agent Account on Nylas evaluates **Rules** against every message — inbound on arrival, outbound on send — and those rules can point at **Lists**, which are typed, editable collections of domains, TLDs, or addresses. Update a list, and every rule that references it picks up the change immediately. No redeploy, no code change, no model in the loop.\n\nThis post goes deep on that pattern: Lists fronted by the `in_list`\n\nrule operator, used for allow *and* block on *both* directions. If you've already read the basic inbound/outbound filtering post, this is the part that makes the filtering dynamic.\n\nThe data plane doesn't change. An Agent Account is just a **grant** with a `grant_id`\n\n, and everything you already know about Messages, Drafts, Threads, and Folders still applies. Rules and Lists sit one level up, on the *control plane* — they're application-scoped admin resources with no grant ID in the path. Your API key identifies the application, and the rules apply to every Agent Account in a workspace.\n\nSo the mental model is two planes:\n\n`GET /v3/grants/{grant_id}/messages`\n\n, `nylas email send`\n\n, the stuff your agent does.`POST /v3/lists`\n\n, `POST /v3/rules`\n\n, the stuff that decides what the agent is A **List** holds values. A **Rule** references a list through the `in_list`\n\noperator and says \"if the sender domain is in this list, block it\" (or assign it, or archive it). Change the list, and the rule's behavior changes with it. That indirection is the whole point: the *what* (which domains) lives in a list a human can edit; the *how* (block vs. route) lives in a rule you set once.\n\nA few honest tradeoffs, because a static `ALLOWED_DOMAINS`\n\nset genuinely is simpler when you only have one agent:\n\n`message.created`\n\nwebhook — so your application never even sees it.`block`\n\nrule can't evaluate a list lookup because of a transient error, Nylas blocks the message rather than letting it slip through. Inbound gets a `451`\n\ntempfail so the sender retries; an API send gets a retryable `503`\n\n.The flip side: if your agent legitimately needs to reach arbitrary external recipients — an open-ended sales tool, say — a static allowlist will block valid sends and frustrate the workflow. Lists fit internal and known-customer agents, not cold outreach. For that case, lean on volume caps and human approval instead, which the [restrict agent recipients](https://developer.nylas.com/docs/cookbook/agents/restrict-agent-recipients/) cookbook recipe covers.\n\nYou need an Agent Account (a grant created via `POST /v3/connect/custom`\n\nagainst a registered domain — see [Agent Accounts](https://developer.nylas.com/docs/v3/agent-accounts/)), an API key for the same application, and the host in your examples set to `https://api.us.nylas.com`\n\n. Every API call below 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 each maps to a distinct piece: `nylas agent list`\n\nmanages list resources (`/v3/lists`\n\n), `nylas agent rule`\n\nmanages rules (`/v3/rules`\n\n), and `nylas workspace update`\n\nattaches a rule to a workspace via its `rule_ids`\n\n. They don't overlap — `nylas agent list`\n\nnever touches workspaces, and a created rule does nothing until a workspace references it (more on that below). Where it matters I'll show both the curl and the CLI form. CLI reference lives at [cli.nylas.com/docs/commands](https://cli.nylas.com/docs/commands).\n\nOne thing to internalize up front about which fields each direction can match, because it's the most common mistake:\n\n`from.*`\n\nfields — `from.address`\n\n, `from.domain`\n\n, `from.tld`\n\n. You only know who sent it.`from.*`\n\n, `recipient.*`\n\n(`recipient.address`\n\n, `recipient.domain`\n\n, `recipient.tld`\n\n), and `outbound.type`\n\n(`compose`\n\nvs. `reply`\n\n).That asymmetry drives everything below. You allowlist *senders* on the way in, and *recipients* on the way out.\n\nA 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 `from.domain`\n\nand `recipient.domain`\n\n, an `address`\n\nlist matches the `.address`\n\nfields, and so on. Start with a domain list, since most allow/block decisions are domain-level.\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\": \"Blocked domains\",\n    \"type\": \"domain\"\n  }'\n```\n\nThe response carries the list `id`\n\nyou'll reference from rules:\n\n```\n{\n  \"request_id\": \"5fa64c92-e840-4357-86b9-2aa364d35b88\",\n  \"data\": {\n    \"id\": \"d1e2f3a4-5678-4abc-9def-0123456789ab\",\n    \"name\": \"Blocked domains\",\n    \"type\": \"domain\",\n    \"items_count\": 0,\n    \"created_at\": 1742932766,\n    \"updated_at\": 1742932766\n  }\n}\n```\n\nSame thing from the CLI, which can seed items at creation time with repeatable `--item`\n\nflags:\n\n```\nnylas agent list create \\\n  --name \"Blocked domains\" \\\n  --type domain \\\n  --item spam-domain.com \\\n  --item another-bad-domain.net\n```\n\nThe `--type`\n\nis `domain`\n\n, `tld`\n\n, or `address`\n\n, and it's immutable — pick it deliberately. The CLI hint says it plainly: the type \"determines which rule fields the list can match in in_list conditions.\"\n\nLists are where the dynamic part lives. Add up to 1000 items per request; values are lowercased, trimmed, and validated against the list's type, so a `domain`\n\nlist rejects full email addresses. Duplicate additions are silently ignored, which means 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\": [\"spam-domain.com\", \"another-bad-domain.net\"]\n  }'\n```\n\nFrom the CLI, items are positional arguments after the list ID:\n\n```\nnylas agent list add <LIST_ID> spam-domain.com another-bad-domain.net\n```\n\nThis is the command you hand to a non-engineer, or wire into an internal admin panel, or call from a webhook handler when an abuse report comes in. It's a single API write that immediately changes the behavior of every rule pointing at the list — no rule edit, no deploy. To pull a domain back out, `nylas agent list remove <LIST_ID> spam-domain.com`\n\n(or `DELETE /v3/lists/{list_id}/items`\n\n).\n\n`in_list`\n\nrule\nNow wire a rule to the list. For inbound, you can only match on the sender, so this is a denylist of sender domains. The key is `\"operator\": \"in_list\"`\n\nwith the list ID — note that `value`\n\nis an **array** of list IDs, not the domains themselves. 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 senders on our blocklist\",\n    \"priority\": 1,\n    \"trigger\": \"inbound\",\n    \"match\": {\n      \"conditions\": [\n        {\n          \"field\": \"from.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. For `in_list`\n\n, the 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 senders on our blocklist\" \\\n  --trigger inbound \\\n  --priority 1 \\\n  --condition from.domain,in_list,<LIST_ID> \\\n  --action block\n```\n\nThe `block`\n\naction is terminal — it can't be combined with other actions. For inbound, it rejects the message at SMTP, so it never reaches the mailbox and never fires a `message.created`\n\nwebhook. Your application is genuinely insulated from that sender.\n\nA note on priority and rule ordering: rules run lowest `priority`\n\nfirst (range 0–1000, default 10), and the first matching `block`\n\nwins. Put your specific `in_list`\n\nblock ahead of any broad `contains`\n\nrules so the precise check fires first.\n\nHere's the step that trips people up: creating the List and the Rule isn't enough. **A rule is inert until a workspace references it.** Workspaces carry policies and 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. One array carries both inbound and outbound rules; Nylas filters by `trigger`\n\nat evaluation time, so the same array can hold your inbound block and your outbound block side by side.\n\nAttach the rule with `PATCH /v3/workspaces/{workspace_id}`\n\n. Pass the *full* set of rule IDs you want active — this replaces the array, so include any existing rules you want to keep:\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\": [\"<INBOUND_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 <INBOUND_RULE_ID>\n```\n\nIf you don't know the workspace ID, the default workspace is the one that holds any Agent Account you haven't placed in a custom workspace, so attaching 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 a rule to apply to a *non-default* workspace, this `PATCH`\n\nis the step that actually turns the rule on. Re-run it whenever you add an outbound rule below, listing every rule ID you want active.\n\nOutbound is where the allowlist pattern gets interesting, because you have two ways to express \"only talk to approved recipients,\" and they fail in opposite directions.\n\n**The blocklist version** — block sends to a list of denied recipient domains — is permissive by default. Anything not on the list goes through. Good for known-bad domains, competitors, test domains that leaked into production:\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    \"trigger\": \"outbound\",\n    \"match\": {\n      \"conditions\": [\n        {\n          \"field\": \"recipient.domain\",\n          \"operator\": \"in_list\",\n          \"value\": [\"<DENY_LIST_ID>\"]\n        }\n      ]\n    },\n    \"actions\": [\n      { \"type\": \"block\" }\n    ]\n  }'\nnylas agent rule create \\\n  --name \"Block sends to denied recipients\" \\\n  --trigger outbound \\\n  --condition recipient.domain,in_list,<DENY_LIST_ID> \\\n  --action block\n```\n\nThis rule, like the inbound one, does nothing until it's on a workspace's `rule_ids`\n\n. Re-run the `PATCH /v3/workspaces/{workspace_id}`\n\n(or `nylas workspace update <WORKSPACE_ID> --rules-ids <INBOUND_RULE_ID>,<OUTBOUND_RULE_ID>`\n\n) with *both* IDs in the array — the same array carries both directions, and Nylas runs each rule only on its matching trigger.\n\n**A true allowlist** — block any recipient *not* on the approved list — is stricter and fails closed. Worth being precise about what the rule engine supports here: `in_list`\n\nmatches when a value *is* present in the list, so it expresses \"block what's on the denylist\" directly. There's no `not_in_list`\n\noperator. For the strict \"deny everything except the allowlist\" semantics, the durable pattern is to keep the hard fail-closed allowlist in your send wrapper — where an empty or unknown domain is denied by default — and use the Nylas `in_list`\n\nblock rule for the dynamic denylist layer on top.\n\nIn practice that split works well: the rule is the editable, no-deploy layer a non-engineer can update; the wrapper is the fail-closed gate your code owns. The [restrict agent recipients](https://developer.nylas.com/docs/cookbook/agents/restrict-agent-recipients/) recipe covers the wrapper side. Don't try to make one do both jobs.\n\nOne important behavioral detail for outbound: `recipient.*`\n\nmatches against **any** recipient, including CC, BCC, and SMTP envelope recipients. That's exactly what you want for data-loss prevention — a hidden BCC to a denied domain still trips the block. An outbound `block`\n\nreturns `403`\n\nto your send call and stores no sent copy; treat it like any other non-retryable delivery failure.\n\n`in_list`\n\nisn't only for blocking. Swap the action and you've got dynamic *routing*. Keep a list of \"VIP customer domains\" and auto-star or folder their mail without touching code when a new customer signs:\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\": \"Star VIP senders\",\n    \"trigger\": \"inbound\",\n    \"match\": {\n      \"conditions\": [\n        {\n          \"field\": \"from.domain\",\n          \"operator\": \"in_list\",\n          \"value\": [\"<VIP_LIST_ID>\"]\n        }\n      ]\n    },\n    \"actions\": [\n      { \"type\": \"mark_as_starred\" }\n    ]\n  }'\nnylas agent rule create \\\n  --name \"Star VIP senders\" \\\n  --trigger inbound \\\n  --condition from.domain,in_list,<VIP_LIST_ID> \\\n  --action mark_as_starred\n```\n\nThe non-blocking actions — `mark_as_starred`\n\n, `mark_as_read`\n\n, `assign_to_folder`\n\n, `archive`\n\n, `mark_as_spam`\n\n, `trash`\n\n— all compose with `in_list`\n\n. Sales adds a domain to the VIP list, and the agent's inbox starts starring that customer's mail. Nobody touched a rule.\n\nA few things I've watched people trip over:\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.`domain`\n\nlist only works with `from.domain`\n\n/ `recipient.domain`\n\n. Point a `from.address`\n\ncondition at a domain list and it won't match. Pick the type to fit the field you'll match.`value`\n\nis an array of list IDs for `in_list`\n\n, not the values.`recipient.*`\n\non the inbound trigger, because there's only one recipient (the agent) and it's already known. Recipient allow/block is outbound-only.`in_list`\n\ncondition, 500 chars per value, 50 conditions and 20 actions per rule.`in_list`\n\nsimply stop matching its values. No dangling references, but also no warning — audit before you delete.`GET /v3/grants/{grant_id}/rule-evaluations`\n\nlists every evaluation most-recent-first, with the matched rule IDs and applied actions. It's the fastest answer to \"why did this message get blocked?\"The combination to remember: a **List** is the editable noun, the `in_list`\n\n**operator** is the indirection, the **trigger** decides whether you're filtering senders (inbound) or recipients (outbound), and the **workspace** `rule_ids`\n\narray is what arms everything. Create the list, create the rule, attach the rule — skip that last step and nothing fires. Once those click, you can hand the *who* to a non-engineer and keep the *how* in version control.\n\n`nylas agent list`\n\n, `nylas agent rule`\n\n, and `nylas workspace update`\n\nreferenceWhen this post is published, link AI agents and crawlers to the retrieval-ready version on `cli.nylas.com`\n\n:", "url": "https://wpnews.pro/news/allowlist-and-denylist-rules-for-agent-email-backed-by-lists", "canonical_source": "https://dev.to/mqasimca/allowlist-and-denylist-rules-for-agent-email-backed-by-lists-44f2", "published_at": "2026-07-12 15:19:34+00:00", "updated_at": "2026-07-12 15:44:11.802193+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["Nylas"], "alternates": {"html": "https://wpnews.pro/news/allowlist-and-denylist-rules-for-agent-email-backed-by-lists", "markdown": "https://wpnews.pro/news/allowlist-and-denylist-rules-for-agent-email-backed-by-lists.md", "text": "https://wpnews.pro/news/allowlist-and-denylist-rules-for-agent-email-backed-by-lists.txt", "jsonld": "https://wpnews.pro/news/allowlist-and-denylist-rules-for-agent-email-backed-by-lists.jsonld"}}