DEV Community

Qasim
Qasim

Posted on

Allowlist and denylist rules for agent email, backed by Lists

The usual way to keep an AI email agent from talking to the wrong people is a hardcoded set in your application code:

ALLOWED_DOMAINS = {"yourcompany.com", "customer.example"}
Enter fullscreen mode Exit fullscreen mode

That 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.

There'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.

This post goes deep on that pattern: Lists fronted by the in_list rule 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.

What you actually get

The data plane doesn't change. An Agent Account is just a grant with a grant_id, 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.

So the mental model is two planes:

  • Data planeGET /v3/grants/{grant_id}/messages, nylas email send, the stuff your agent does.
  • Control planePOST /v3/lists, POST /v3/rules, the stuff that decides what the agent is allowed to do.

A List holds values. A Rule references a list through the in_list operator 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.

Why this beats a code-side allowlist

A few honest tradeoffs, because a static ALLOWED_DOMAINS set genuinely is simpler when you only have one agent:

  • Editable without a deploy. Someone updates a list through the API or a CLI command, and the change is live. No PR, no release. This is the reason to reach for it.
  • One list, many rules. A single "Blocked domains" list can feed an inbound block rule and an outbound block rule. Update it once, both directions follow.
  • Both directions. A code-side check before your send call only covers outbound. Rules also reject inbound mail at SMTP, before it ever lands in the mailbox or fires a message.created webhook — so your application never even sees it.
  • Fails closed on infrastructure errors. If a block rule 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 tempfail so the sender retries; an API send gets a retryable 503.

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 cookbook recipe covers.

Before you begin

You need an Agent Account (a grant created via POST /v3/connect/custom against a registered domain — see Agent Accounts), an API key for the same application, and the host in your examples set to https://api.us.nylas.com. Every API call below 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 each maps to a distinct piece: nylas agent list manages list resources (/v3/lists), nylas agent rule manages rules (/v3/rules), and nylas workspace update attaches a rule to a workspace via its rule_ids. They don't overlap — nylas agent list never 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.

One thing to internalize up front about which fields each direction can match, because it's the most common mistake:

  • Inbound rules can only match from.* fields — from.address, from.domain, from.tld. You only know who sent it.
  • Outbound rules can match from.*, recipient.* (recipient.address, recipient.domain, recipient.tld), and outbound.type (compose vs. reply).

That asymmetry drives everything below. You allowlist senders on the way in, and recipients on the way out.

Create a List of domains

A list has a fixed typedomain, tld, or address — set at creation and immutable. The type decides which rule fields it can match: a domain list matches from.domain and recipient.domain, an address list matches the .address fields, and so on. Start with a domain list, since most allow/block decisions are domain-level.

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

The response carries the list id you'll reference from rules:

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

Same thing from the CLI, which can seed items at creation time with repeatable --item flags:

nylas agent list create \
  --name "Blocked domains" \
  --type domain \
  --item spam-domain.com \
  --item another-bad-domain.net
Enter fullscreen mode Exit fullscreen mode

The --type is domain, tld, or address, 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."

Add items to a List

Lists 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 list rejects full email addresses. Duplicate additions are silently ignored, which means 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": ["spam-domain.com", "another-bad-domain.net"]
  }'
Enter fullscreen mode Exit fullscreen mode

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

nylas agent list add <LIST_ID> spam-domain.com another-bad-domain.net
Enter fullscreen mode Exit fullscreen mode

This 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 (or DELETE /v3/lists/{list_id}/items).

Block inbound mail with an in_list rule

Now 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" with the list ID — note that value is an array of list IDs, not the domains themselves. 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 senders on our blocklist",
    "priority": 1,
    "trigger": "inbound",
    "match": {
      "conditions": [
        {
          "field": "from.domain",
          "operator": "in_list",
          "value": ["<LIST_ID>"]
        }
      ]
    },
    "actions": [
      { "type": "block" }
    ]
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI condition format is field,operator,value. 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 senders on our blocklist" \
  --trigger inbound \
  --priority 1 \
  --condition from.domain,in_list,<LIST_ID> \
  --action block
Enter fullscreen mode Exit fullscreen mode

The block action 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 webhook. Your application is genuinely insulated from that sender.

A note on priority and rule ordering: rules run lowest priority first (range 0–1000, default 10), and the first matching block wins. Put your specific in_list block ahead of any broad contains rules so the precise check fires first.

Activate the rule on a workspace

Here'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 array, 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 at evaluation time, so the same array can hold your inbound block and your outbound block side by side.

Attach the rule with PATCH /v3/workspaces/{workspace_id}. Pass the full set of rule IDs you want active — this replaces the array, so include any existing rules you want to keep:

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": ["<INBOUND_RULE_ID>"]
  }'
Enter fullscreen mode Exit fullscreen mode

From the CLI, nylas workspace update takes a comma-separated list of rule IDs:

nylas workspace update <WORKSPACE_ID> --rules-ids <INBOUND_RULE_ID>
Enter fullscreen mode Exit fullscreen mode

If 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 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 a rule to apply to a non-default workspace, this PATCH is 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.

Allow vs. block on outbound

Outbound 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.

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:

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",
    "trigger": "outbound",
    "match": {
      "conditions": [
        {
          "field": "recipient.domain",
          "operator": "in_list",
          "value": ["<DENY_LIST_ID>"]
        }
      ]
    },
    "actions": [
      { "type": "block" }
    ]
  }'
Enter fullscreen mode Exit fullscreen mode
nylas agent rule create \
  --name "Block sends to denied recipients" \
  --trigger outbound \
  --condition recipient.domain,in_list,<DENY_LIST_ID> \
  --action block
Enter fullscreen mode Exit fullscreen mode

This rule, like the inbound one, does nothing until it's on a workspace's rule_ids. Re-run the PATCH /v3/workspaces/{workspace_id} (or nylas workspace update <WORKSPACE_ID> --rules-ids <INBOUND_RULE_ID>,<OUTBOUND_RULE_ID>) with both IDs in the array — the same array carries both directions, and Nylas runs each rule only on its matching trigger.

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 matches when a value is present in the list, so it expresses "block what's on the denylist" directly. There's no not_in_list operator. 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 block rule for the dynamic denylist layer on top.

In 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 recipe covers the wrapper side. Don't try to make one do both jobs.

One important behavioral detail for outbound: recipient.* matches 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 returns 403 to your send call and stores no sent copy; treat it like any other non-retryable delivery failure.

Route inbound mail by list instead of blocking

in_list isn'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:

curl --request POST \
  --url "https://api.us.nylas.com/v3/rules" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Star VIP senders",
    "trigger": "inbound",
    "match": {
      "conditions": [
        {
          "field": "from.domain",
          "operator": "in_list",
          "value": ["<VIP_LIST_ID>"]
        }
      ]
    },
    "actions": [
      { "type": "mark_as_starred" }
    ]
  }'
Enter fullscreen mode Exit fullscreen mode
nylas agent rule create \
  --name "Star VIP senders" \
  --trigger inbound \
  --condition from.domain,in_list,<VIP_LIST_ID> \
  --action mark_as_starred
Enter fullscreen mode Exit fullscreen mode

The non-blocking actions — mark_as_starred, mark_as_read, assign_to_folder, archive, mark_as_spam, trash — all compose with in_list. Sales adds a domain to the VIP list, and the agent's inbox starts starring that customer's mail. Nobody touched a rule.

Guardrails and gotchas

A few things I've watched people trip over:

  • A rule does nothing until it's on a workspace. The most common "my rule isn't firing" cause is a rule that was created but never added to a workspace's 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. The PATCH replaces the array, it doesn't append. List every rule ID you want active each time, or you'll silently detach the ones you left out.
  • List type must match the field. A domain list only works with from.domain / recipient.domain. Point a from.address condition at a domain list and it won't match. Pick the type to fit the field you'll match.
  • value is an array of list IDs for in_list, not the values. The domains live in the list; the rule references the list by ID. This catches everyone once.
  • Inbound can't see recipients. You cannot allowlist recipients on inbound rules — there's no recipient.* on the inbound trigger, because there's only one recipient (the agent) and it's already known. Recipient allow/block is outbound-only.
  • Up to 10 lists per in_list condition, 500 chars per value, 50 conditions and 20 actions per rule. Generous, but real caps.
  • Deleting a list cascades. Items go with it, and rules that referenced it via in_list simply stop matching its values. No dangling references, but also no warning — audit before you delete.
  • Audit what actually fired. When something gets blocked or routed unexpectedly, GET /v3/grants/{grant_id}/rule-evaluations lists 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?"

What's next

The combination to remember: a List is the editable noun, the in_list operator is the indirection, the trigger decides whether you're filtering senders (inbound) or recipients (outbound), and the workspace rule_ids array 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.

AI-answer pages for agents

When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:

Top comments (0)