Send lists
Send lists are allow/blocklists that gate outbound recipients. Every send runs its single resolved recipient through the list policy before delivery. Lists exist at two scopes: account (applies to every mailbox) and mailbox (applies to one mailbox).
Send lists are managed in the dashboard (a control-plane action); the data-plane API only enforces them.
Precedence
The policy engine evaluates entries in a fixed order. With no entries at a scope, that scope imposes no restriction.
- Deny wins. Block entries are checked before allow entries. An account block match rejects first; then a mailbox block match.
- Allow-sets intersect per scope. If a scope has any allow entries, the recipient must match one of that scope's entries. The account allow-set and the mailbox allow-set are checked independently — matching the mailbox allow-set does not satisfy a non-empty account allow-set, and vice versa.
- The account scope is a floor. A non-empty account allow-set constrains every mailbox; no mailbox-level entry can widen past it.
So a recipient passes only if: it hits no block at either scope, and it satisfies the account allow-set (if any), and it satisfies the mailbox allow-set (if any).
Normalization
Both list patterns (at write time) and recipients (at check time) go through the same canonicalizer, so matching is exact equality on canonical forms:
- Case-folded. Local part and domain are lowercased.
- IDN → punycode. Unicode domains are converted with WHATWG
domainToASCII(café.com→xn--caf-dma.com). - One trailing dot stripped.
example.com.→example.com, so a trailing-dot recipient can't dodge a block. - No plus-address stripping.
a+tag@x.comstays distinct froma@x.com— a subaddress is a different address. - Empty-label garbage (
x..com) is rejected at both write and check time.
A pattern containing @ is an exact address; a bare pattern is an exact domain (matched
against the domain after the recipient's last @).
Exact-label matching — blocks do NOT cover subdomains
Matching is exact equality. There is no wildcard, suffix, or subdomain matching.
A block on bad.com does NOT cover sub.bad.com. To block a subdomain you must add it as
its own entry. Likewise an address entry user@bad.com matches only that exact address, not
the whole domain.
The 403 send_blocked_by_list response
When a recipient is blocked (or fails a required allow-set), POST /api/v1/send returns 403
with this structured body (there is no error string on list rejections):
{
"code": "send_blocked_by_list",
"reason": "block_match",
"scope": "account",
"list_entry_id": "le_123..."
}
reasonisblock_match(hit a blocklist entry) orno_allow_match(failed a required allow-set).scopeisaccountormailbox— which scope produced the rejection.list_entry_idis present only onblock_match(it points at the blocking entry). Ano_allow_matchhas no single entry to cite, so the field is omitted.
Current limitations
- Exact-label only — no subdomain coverage. A block on
bad.comdoes not blocksub.bad.com; add each subdomain (and each address) explicitly. This is the biggest gap to be aware of when relying on blocklists. - Fail-closed rejections always report
scope: "account". If a recipient can't be canonicalized at all (malformed address) and any list policy exists, the send is rejected{"code": "send_blocked_by_list", "reason": "no_allow_match", "scope": "account"}— thescopeis hard-codedaccounton this fail-closed path regardless of which scope's entries exist.