Sending to dead or hostile addresses tanks your deliverability. A hard bounce means the mailbox doesn't exist; a complaint means a human hit "this is spam." Mailbox providers watch both rates obsessively, and once yours climb, they stop trusting your domain β your good mail starts landing in spam too. The cruel part is that an autonomous email agent will happily keep hammering a bad address forever unless you stop it, because nothing in a naive send loop ever learns that an address went bad.
Most "AI email" hygiene advice stops at "authenticate your domain and warm it up." That's table stakes. The interesting problem is the feedback loop: the agent sends, some sends fail or get reported, and you need that failure signal to flow back into the agent's behavior automatically. No human in the loop scrubbing CSVs. This post wires deliverability webhooks straight into a suppression List and a block rule, so an address that bounces or complaints once is never mailed again.
I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for. Every step shows both the raw HTTP call and the CLI equivalent, because that's how I actually debug this stuff β curl to see the wire, CLI to do it fast.
The whole thing is a four-piece loop:
message.bounced
, message.complaint
) tell you which addresses went bad, in real time.address
holds those bad addresses.recipient.address in_list <suppression-list>
and rejects any send to a suppressed address before it leaves the building.The elegant part is that pieces 2 and 3 are pure Nylas config β Policies, Rules, and Lists. Your code never has to maintain a denylist in your own database, never has to remember to check it before sending, and never has to special-case which agent is sending. The block happens at the platform, on the outbound path, for every Agent Account in the workspace. You just keep the list fed.
And the data plane is the same one you already know. An Agent Account is just a grant with a grant_id
β it works with every grant-scoped endpoint, including Webhooks. There's nothing new to learn to receive these events; if you've subscribed to message.created
before, this is the same mechanism with different trigger types.
You could absolutely keep a suppressed_emails
table and check it before every send. People do. Here's why I don't:
The honest tradeoff: you're trusting Nylas to evaluate the rule on every send, and an outbound block
returns a 403
your code has to handle like any delivery failure. If you want the suppression logic to live entirely in your own stack for portability reasons, the database approach is fine. For an autonomous agent where the whole point is not writing glue code, the rule wins.
You need:
provider: "nylas"
) on a verified domain. If you don't have one, see NYLAS_API_KEY
and the API base host. Examples here use https://api.us.nylas.com
.New to Nylas? Start at the
[Agent Accounts overview]β it explains the grant model the rest of this assumes.
One thing to get straight up front about scope. Policies, Rules, and Lists are application-scoped and carried by workspaces, not individual grants. You attach a policy_id
and rule_ids
to a workspace, and every Agent Account in that workspace inherits them. Each application has a default workspace that holds any account you haven't placed in a custom one, so attaching the suppression rule there covers all your unassigned agents at once. The CLI commands below resolve the default workspace from your current default grant automatically.
This is the one step where the CLI can't help you, and I want to be honest about why. Agent Accounts emit four deliverability webhooks β message.delivered
, message.bounced
, message.complaint
, and message.rejected
β and these are the events Nylas itself uses to calculate your bounce and complaint rates. They're real API trigger types. But the CLI's nylas webhook triggers
list doesn't include them yet; it still shows the older transactional triggers like message.bounce_detected
. So for this subscription, you go through the API directly.
Create a webhook destination subscribed to the two triggers that matter for suppression:
curl --request POST \
--url "https://api.us.nylas.com/v3/webhooks" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"trigger_types": ["message.bounced", "message.complaint"],
"webhook_url": "https://yourapp.example.com/webhooks/nylas",
"notification_email_addresses": ["ops@yourcompany.com"],
"description": "Agent Account suppression feed"
}'
You can add message.delivered
and message.rejected
to the same trigger_types
array if you also want to track positive delivery and virus-rejected sends β message.rejected
fires specifically when an outbound message is rejected because one or more attachments contained a virus, not as a generic rejection signal. Both are useful for dashboards, but neither drives suppression. For suppression specifically, message.bounced
and message.complaint
are the two you act on.
CLI note (the honest version): because message.bounced
and message.complaint
aren't in the CLI's trigger enum, nylas webhook create --triggers message.bounced
will reject the value. The CLI is great for the other webhook types β nylas webhook create --url https://yourapp.example.com/webhooks/nylas --triggers message.created
works fine β but for the deliverability triggers, subscribe via the curl call above. If you want to confirm what the CLI does expose, run nylas webhook triggers --category message
and you'll see the bounced/complaint deliverability triggers are absent.
Now the part that is fully CLI-friendly. Create an address
-typed List to hold the addresses you'll suppress. The type is immutable, and it determines which rule fields the list can match β an address
list matches from.address
and recipient.address
, which is exactly what we want for outbound suppression.
API:
curl --request POST \
--url "https://api.us.nylas.com/v3/lists" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"name": "Suppressed addresses",
"type": "address"
}'
The response hands back the list id
you'll need for both adding items and wiring the rule:
{
"request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88",
"data": {
"id": "d1e2f3a4-5678-4abc-9def-0123456789ab",
"name": "Suppressed addresses",
"type": "address",
"items_count": 0,
"created_at": 1742932766,
"updated_at": 1742932766
}
}
CLI:
nylas agent list create --name "Suppressed addresses" --type address
That prints the new list ID. If you want to seed it with known-bad addresses at creation time, pass --item
(repeatable):
nylas agent list create --name "Suppressed addresses" --type address \
--item known-bad@example.com
When your handler decides an address is dead, it adds it to the list. Up to 1000 items per request; values are lowercased, trimmed, and validated against the list's type, and duplicate additions are silently ignored β which is exactly the behavior you want, because the same address can bounce more than once and you don't want to special-case that.
API:
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": ["jane@example.com"]
}'
CLI:
nylas agent list add <LIST_ID> jane@example.com
The silent dedupe is the detail I appreciate as an SRE. You don't have to read-before-write or guard against races between two bounce events for the same address arriving at once. Just add; the API sorts it out.
The list does nothing on its own β it's just data. The rule is what makes it bite. Create an outbound rule that blocks any send where a recipient address is in_list
your suppression list. Because block
is terminal and evaluated before the message reaches the provider, a suppressed recipient means the send is rejected with 403
and no sent copy is stored.
One subtlety worth knowing: recipient.address
matches against any recipient β To, CC, BCC, and the SMTP envelope. So if a single suppressed address is buried in the BCC of a 50-recipient send, the whole send is blocked. For suppression that's usually the right call; you'd rather fail loudly than quietly mail a known-bad address. Just be aware of it when you design batch sends.
API:
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 suppressed addresses",
"priority": 1,
"trigger": "outbound",
"match": {
"conditions": [
{
"field": "recipient.address",
"operator": "in_list",
"value": ["<LIST_ID>"]
}
]
},
"actions": [
{ "type": "block" }
]
}'
CLI β the --condition
flag takes field,operator,value
, and for in_list
the value is the list ID:
nylas agent rule create \
--name "Block sends to suppressed addresses" \
--trigger outbound \
--priority 1 \
--condition recipient.address,in_list,<LIST_ID> \
--action block
The CLI creates the rule through /v3/rules
and attaches it to the default workspace for you, resolved from your current default grant β so the CLI path is already activated. The API path is not: a rule created through POST /v3/rules
is inert until a workspace references it. That's the next step.
A rule does nothing until a workspace references it. If you created the rule through the API, add its ID to a workspace's rule_ids
array to make it run. That array carries inbound and outbound rules together; Nylas filters by trigger
at evaluation time, so listing the rule here is what makes it fire for every Agent Account in the workspace.
API:
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": ["<RULE_ID>"]
}'
CLI β the flag is --rules-ids
(comma-separated):
nylas workspace update <WORKSPACE_ID> --rules-ids <RULE_ID>
Attach to the default workspace if you don't manage workspaces explicitly β it holds every Agent Account you haven't placed in a custom one, so suppression covers all your unassigned agents at once. One caveat: rule_ids
replaces the array, so include any existing rule IDs you want to keep alongside the new one.
I set priority: 1
deliberately. Rules run lowest-number-first, and the first matching block
is terminal. You want suppression to be the first thing evaluated on the outbound path, ahead of any routing or archiving rules, so a suppressed send dies immediately and never burns evaluation on rules that no longer matter.
Here's the only code you write. The two payloads carry the offending addresses in slightly different shapes, so handle each trigger type explicitly rather than guessing.
A message.bounced
event puts the bounced recipients under data.object.bounce.recipients
, each an object with an email
field (and sometimes a diagnostic_code
):
{
"type": "message.bounced",
"data": {
"grant_id": "<NYLAS_GRANT_ID>",
"object": {
"bounce": {
"type": "MailboxFull",
"recipients": [
{ "email": "jane@example.com", "diagnostic_code": "smtp; 550 user unknown" }
]
}
}
}
}
A message.complaint
event puts them under data.object.complaint.complained_recipients
, a flat array of address strings:
{
"type": "message.complaint",
"data": {
"grant_id": "<NYLAS_GRANT_ID>",
"object": {
"complaint": {
"type": "abuse",
"complained_recipients": ["jordan.taylor@example.com"]
}
}
}
}
So the handler logic is: branch on type
, pull the addresses out of the right field, and POST them to your list's /items
endpoint. In pseudocode:
on webhook (event):
if event.type == "message.bounced":
addresses = [r.email for r in event.data.object.bounce.recipients]
elif event.type == "message.complaint":
addresses = event.data.object.complaint.complained_recipients
else:
return # ignore everything else
POST https://api.us.nylas.com/v3/lists/<LIST_ID>/items
{ "items": addresses }
That's the entire feedback loop. From here on, any agent in the workspace that tries to mail one of those addresses gets a 403
, and you never have to think about it again.
A few things I'd want a teammate to know before shipping this:
403
on send.403
and stores no sent copy. Treat it exactly like any other permanent delivery failure β there is no retry path that will deliver it. If your agent has retry logic, make sure a 403
short-circuits it instead of looping.in_list
lookup hits a transient infrastructure error, Nylas blocks the send rather than letting it through β but it returns 503
(not 403
) and inbound SMTP returns a 451
tempfail, so the distinction is retryable-vs-permanent. A 503
means "try again later"; a 403
means "this address is genuinely suppressed." Don't collapse them.MailboxFull
bounce can be transient β that mailbox might come back tomorrow. If you suppress aggressively on every bounce type, you'll permanently lose recipients who had a full inbox for a day. Consider only suppressing on hard bounces (nonexistent mailbox, 550 user unknown
) and treating soft bounces as a back-off signal instead. The diagnostic_code
and type
fields give you what you need to make that call.GET /v3/grants/{grant_id}/rule-evaluations
to see exactly which rule matched and why. The records carry the normalized recipient data and the matched rule IDs, so you can prove a block came from suppression and not from something else. This one is API-only β there's no rule-evaluations
subcommand under nylas agent
, so reach for curl:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/rule-evaluations?limit=50" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
nylas agent rule create
or nylas agent list add
into a pipeline, run the command with --help
and confirm the flags. That's the same habit that caught the missing deliverability triggers above.nylas agent
and nylas webhook
Authentication gets you delivered. Suppression keeps you delivered. The two together are the difference between an agent that ages into spam folders and one that stays in the inbox because it learned to stop knocking on doors that don't open.
When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com
: