---
title: Send lists
description: Allow/blocklist precedence, address normalization, exact-label matching, and the 403 send_blocked_by_list shape.
---

# 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é.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.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):

```json
{
  "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.
