DEV Community

Qasim Muhammad
Qasim Muhammad

Posted on

Auto-Route Invoices With Inbound Email Rules

A single inbound email rule can carry up to 50 match conditions and 20 actions — and it runs inside the mail infrastructure itself, before your webhook handler, your queue, or any line of your application code gets involved.

That changes how you build something like invoice routing. The usual approach is: receive message.created, fetch the message, check the sender, call the folders endpoint to move it. Four network hops and a handler you have to deploy and monitor. With Agent Accounts — programmatic mailboxes currently in beta — there's a declarative alternative: Rules that match sender fields and run assign_to_folder so invoices are already sitting in the finance folder when your application first sees them.

The three resources and how they chain

The Policies, Rules, and Lists docs define a chain:

  • Lists are typed collections of domains, TLDs, or addresses.
  • Rules match inbound or outbound mail on sender/recipient fields and run actions: block, mark_as_spam, assign_to_folder, mark_as_read, mark_as_starred, archive, or trash.
  • Policies bundle limits and spam settings.

None of them attach to an individual mailbox. Instead, a workspace carries one policy_id plus an array of rule_ids, and every agent mailbox in that workspace inherits the lot. Each application gets a default workspace that holds any account you haven't placed elsewhere — attach your rules there once and every unassigned mailbox picks them up. All of this is optional, too: with no workspace policy attached, an account simply runs at your billing plan's maximum limits and delivers every inbound message straight to the inbox.

Build the invoice rule

Say invoices arrive from a handful of vendors. One rule, OR'd conditions, two actions:

curl --request POST \
  --url "https://api.us.nylas.com/v3/rules" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Invoices → Finance folder",
    "trigger": "inbound",
    "match": {
      "operator": "any",
      "conditions": [
        { "field": "from.domain", "operator": "is", "value": "billing.vendor-a.com" },
        { "field": "from.address", "operator": "contains", "value": "invoice@" }
      ]
    },
    "actions": [
      { "type": "assign_to_folder", "value": "<FINANCE_FOLDER_ID>" },
      { "type": "mark_as_read" }
    ]
  }'
Enter fullscreen mode Exit fullscreen mode

A rule does nothing until a workspace references it — add its ID to the workspace's rule_ids array and it's live for every account there. Inbound rules can match from.address, from.domain, and from.tld with the operators is, is_not, contains, and in_list. Matching is case-insensitive.

Vendors change — use a List

Hardcoding domains in rule conditions works until accounting onboards a new vendor and files a ticket. A domain-typed List fixes that. Create it, load it, and point the rule at it:

# 1. Create the list (type is immutable after creation)
curl --request POST \
  --url "https://api.us.nylas.com/v3/lists" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{ "name": "Invoice vendors", "type": "domain" }'

# 2. Add vendor domains — up to 1,000 items per request
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": ["billing.vendor-a.com", "invoices.vendor-b.io"] }'
Enter fullscreen mode Exit fullscreen mode

Then the rule condition becomes:

{ "field": "from.domain", "operator": "in_list", "value": ["<LIST_ID>"] }
Enter fullscreen mode Exit fullscreen mode

Now whoever maintains the vendor roster updates the List without touching the rule or redeploying anything — every rule that references the list picks up new values immediately. Values get lowercased, trimmed, and validated against the list type on write (a domain list rejects full email addresses), and duplicates are silently ignored. Lists come in three types — domain, tld, and address — and the type decides which rule fields they can match. One caution: deleting a list cascades to its items, and any rule matching it through in_list silently stops matching those values.

The caps you'll design around

Rules have fixed limits, and requests that exceed them are rejected with a validation error:

Cap Value
Conditions per rule 50
Actions per rule 20
Lists per in_list condition 10
Characters per condition value 500

Fifty conditions sounds like a lot until you start inlining vendor addresses one condition at a time — which is exactly why the List pattern above exists. Ten lists per in_list condition, each holding thousands of entries, scales much further than inline values ever will.

What rules can't do (and where your code still matters)

Inbound rules match exactly three fields: from.address, from.domain, and from.tld. There's no subject matching, no body matching, no "has attachment" condition. So a rule can route mail from billing.vendor-a.com to the finance folder, but it can't catch "an invoice from an unknown sender." That split is actually a decent architecture: deterministic sender-based routing lives in the rule layer, and content-based classification — "is this PDF actually an invoice?" — stays in your application or LLM, working over a pre-sorted folder instead of a raw inbox.

Two adjacent facts from the policy layer are relevant here too. Attachment limits (size, count, and allowed MIME types) on the workspace policy apply to inbound mail only — over-limit attachments are dropped from the stored message — so a policy with a 26214400-byte (25 MB) attachment cap protects your invoice parser from absurd payloads before any code runs. And the same rule_ids array also carries outbound rules, evaluated when the account sends; Nylas filters by trigger at evaluation time, so the two directions never cross-fire.

Ordering and the fail-closed surprise

Rules evaluate in priority order — lower runs first, the range is 0–1000, and the default is 10. The block action is terminal: for inbound mail it rejects at the SMTP stage, so a spam-block rule at priority 1 means junk never even reaches your invoice rule, let alone your LLM or your handler.

The behavior worth knowing before an incident: rule evaluation fails closed. If a block rule can't be evaluated because of a transient infrastructure error — say a list lookup fails mid-in_list match — the message gets blocked rather than waved through. Inbound SMTP responds with a 451 tempfail so the sending server retries instead of bouncing. The audit record carries blocked_by_evaluation_error: true so you can tell an infrastructure hiccup apart from a genuine match.

Answering "why is this invoice in spam?"

Every evaluation writes an audit entry. GET /v3/grants/{grant_id}/rule-evaluations lists them newest-first, with the evaluation stage, the normalized sender data that was considered, the matched rule IDs, and the applied actions. When finance asks why a vendor's invoice vanished, that's a one-call answer instead of a log-diving session.

A starting point

Create one domain List with your top three vendors, one assign_to_folder rule referencing it, attach both to your default workspace, and send yourself a test invoice. Then check the rule-evaluations endpoint to watch the match happen. Total setup is three API calls. What's the most annoying piece of inbox sorting you're currently doing in application code?

Top comments (0)