View as markdown ↓

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.

  1. Deny wins. Block entries are checked before allow entries. An account block match rejects first; then a mailbox block match.
  2. 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.
  3. 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é.comxn--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.com stays distinct from a@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..."
}
  • reason is block_match (hit a blocklist entry) or no_allow_match (failed a required allow-set).
  • scope is account or mailbox — which scope produced the rejection.
  • list_entry_id is present only on block_match (it points at the blocking entry). A no_allow_match has no single entry to cite, so the field is omitted.

Current limitations

  • Exact-label only — no subdomain coverage. A block on bad.com does not block sub.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"} — the scope is hard-coded account on this fail-closed path regardless of which scope's entries exist.