# Inbound firewall (sender allowlist + blocklist)

The inbound firewall is the symmetric counterpart to the outbound
[recipient allowlist](/docs/guides/recipient-allowlist) and
[suppression list](/docs/guides/suppressions): it gates *incoming* mail by the
sender. Every mailbox runs in one of two modes:

- **`blocklist` (default)** — accept everyone except senders you've explicitly
  blocked account-wide.
- **`allowlist`** — accept only senders on that mailbox's explicit list; block
  everyone else.

When the firewall rejects a message it lands in the **`firewall_blocked`** state:
visible to you, recoverable via a release endpoint, and **never delivered to
agents**. An audit entry and a webhook fire on every block.

## At a glance

| Action | Who can do it | Notes |
|---|---|---|
| **Add to the account-wide blocklist** | Admin + agent keys, dashboard | Server forces `reason='manual'`, `source='customer'`. Idempotent. |
| **Add to a mailbox's allowlist** | Admin + agent keys, dashboard | Diverges from the outbound allowlist (which is admin-only) — see [Why mutations are admin + agent](#why-mutations-are-admin--agent). |
| **Remove from the blocklist** | Admin + agent keys, dashboard | Diverges from outbound suppressions (admin-only remove). |
| **Remove from an allowlist** | Admin + agent keys, dashboard | Removing the last entry while in `allowlist` mode returns `409 INBOUND_ALLOWLIST_LAST_ENTRY`; override with `?force_empty=true`. |
| **Flip a mailbox's mode** | Admin + agent keys, dashboard | `PATCH /v1/mailboxes/:id/sender-policy`. Flipping to `allowlist` with an empty list returns `409 SENDER_POLICY_FLIP_EMPTY_ALLOWLIST` unless `force_empty` is set. |
| **Release a `firewall_blocked` message** | Admin + agent keys (mailbox-bound), dashboard | `POST /v1/messages/:id/firewall-release`. Atomic: state moves to `scanning` and the scanner job is enqueued in the same transaction. |

Unlike the outbound allowlist — which *contains agents* by limiting what they can
send — the inbound firewall *protects the account* from external senders. That
difference in threat model is why the auth rules diverge; see
[below](#why-mutations-are-admin--agent).

## Why use it

- **Block specific spammers or abusers** without relying only on platform-level
  abuse filters. Your blocklist is always-on and runs alongside ReplyLayer's built-in protections.
- **Contain a high-stakes inbox.** Switch a mailbox to `allowlist` mode so only
  known senders (vendors, internal automation) can reach it; everyone else lands
  in `firewall_blocked`, reviewable but never auto-delivered.

## How the two modes compare

| | `blocklist` (default) | `allowlist` |
|---|---|---|
| **What's checked at ingest** | The account-wide blocklist | The account-wide blocklist first, then the mailbox's allowlist |
| **Who's rejected** | Senders on the blocklist | Senders not on the allowlist (or on the blocklist) |
| **Scope** | Account-wide | Per mailbox |
| **`firewall_block.reason_code`** | `SENDER_BLOCKED` | `SENDER_NOT_ON_ALLOWLIST` |

**The blocklist wins on overlap.** A sender that appears on both the blocklist and
a mailbox's allowlist is rejected with `SENDER_BLOCKED` — the blocklist check runs
first and short-circuits.

## Sender match rules

- The firewall matches against **both** the SMTP envelope sender (the `MAIL FROM`
  address) **and** the parsed `From:` header. A match on either one rejects the
  message. The `firewall_block.matched_field` value tells you which matched
  (`envelope` or `from`).
- Entries are either exact addresses (`alice@spam.com`) or bare-domain patterns
  (`@spam.com`, which matches every address at that domain).
- Sub-domains do **not** match the parent: `@spam.com` does not catch
  `eve@sub.spam.com`. Add an explicit `@sub.spam.com` entry for each sub-domain.
- Matching is deterministic — when several patterns match, **most-specific wins**
  (an exact email beats a `@domain` pattern); ties break alphabetically.
- All entries are stored lowercased, and the gate lowercases the envelope and
  `From:` values before comparing. There are no wildcards (`@*.spam.com`), IP
  ranges, or regex — exact address or `@domain` only.

## Verified replies bypass the firewall

When your agent sends an outbound message using the platform's signed reply
headers, the *reply to that message* is cryptographically verified as a
round-trip of your own outbound. **Verified replies bypass the firewall on every
inbound path.** This is intentional: even if you accidentally add your own sending
domain to the blocklist, agent reply round-tripping keeps working.

The bypass lives at the reply-verification layer, so it shares the same trust
model as your reply signing — see [Sub-addressing and secure
replies](/docs/guides/sub-addressing) for how those headers work.

## What `firewall_blocked` looks like

A blocked message:

- **Retains the raw message** so you can review exactly what was attempted.
- Sets `state='firewall_blocked'` and populates a `firewall_block` object with the
  snapshot the gate evaluated.
- **Skips the content scanner** at ingest — no quarantine, no scan results yet.
- Is **excluded from the default list endpoints.** Both the message list
  (`GET /v1/mailboxes/:id/messages`) and the thread endpoints omit
  `firewall_blocked` rows so integrations don't silently start seeing a new state.
  Pass `?include_firewall_blocked=true` on any of them to opt back in (the
  dashboard's Firewall tab does this).
- **Does** appear on a single-message fetch — `GET /v1/messages/:id` always
  returns the row regardless of state.

Read responses carry a `firewall_block` field (populated only when
`state='firewall_blocked'`, `null` otherwise):

```json
"firewall_block": {
  "envelope_sender": "attacker@spam.com",
  "from_address": "attacker@spam.com",
  "matched_field": "envelope",
  "matched_pattern": "@spam.com",
  "reason_code": "SENDER_BLOCKED",
  "matched_list": "account_blocklist",
  "mode": "blocklist"
}
```

If the mailbox has `pii_mode='redacted'`, the sender fields
(`envelope_sender`, `from_address`, `matched_pattern`) become `<REDACTED>`; the
categorical fields (`matched_field`, `reason_code`, `matched_list`, `mode`) pass
through so you can still reason about *why* the block happened.

## Releasing a blocked message

`POST /v1/messages/:id/firewall-release` sends the message into normal scanner
processing. Because a firewall-blocked message skipped the scanner at block time,
releasing it runs the content scanner on the stored message for the first time and
lands it at the scanner's verdict — `available`, `quarantined`, or `blocked`.

```ts
await rl.messages.firewallRelease(messageId);
// 202 { message_id, state: 'scanning' }
```

The call returns `202` immediately with `state: 'scanning'`. Poll
`GET /v1/messages/:id` (or watch the `message.received` / `message.quarantined` /
`message.scanner_blocked` lifecycle webhook) for the final verdict. See
[Message lifecycle](/agents/messages) for what each verdict means and
[Content scanning](/docs/guides/content-scanning) for the quarantine-vs-block
distinction.

Releasing does **not** add the sender to the allowlist or remove it from the
blocklist. If you want either side effect, call the matching add/remove endpoint
separately.

**Errors on release:**

- `404 NOT_FOUND` — the message doesn't exist or belongs to another account.
- `409 INVALID_STATE` — the message isn't in `firewall_blocked` (the current state
  is returned).
- `403 MAILBOX_ACCESS_DENIED` — the agent key is bound to a different mailbox.

## Why mutations are admin + agent

The outbound allowlist is admin-only because letting an agent add allowlist
entries would defeat containment — an agent could authorize an
attacker-controlled recipient. The inbound side is the mirror image:

- Adding a sender to a mailbox's **allowlist** only *widens* what can reach the
  mailbox. It grants the agent no new send capability.
- Adding a sender to the **blocklist** only *narrows* what can reach the mailbox.
- Both directions are account-protective. There's no "agent escaping
  containment" risk, so both surfaces accept admin **and** agent keys.

Mailbox-scoped routes still enforce mailbox scoping: a mailbox-bound agent key can
only mutate the mailbox(es) it's bound to.

## Quickstart

### TypeScript

```ts
import { ReplyLayer } from '@replylayer/sdk';
const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });

// 1. Block a sender account-wide.
await rl.inboundBlocklist.add({ email: '@spam-domain.com' });

// 2. Switch a high-stakes mailbox into allowlist mode.
await rl.mailboxes.inboundAllowlist.add(mailboxId, { email: 'finance@partner.com' });
await rl.mailboxes.inboundAllowlist.add(mailboxId, { email: '@vendor.com' });
await rl.mailboxes.setSenderPolicy(mailboxId, 'allowlist');

// 3. Inspect what the gate has rejected (default: aggregated top-N).
const blocked = await rl.mailboxes.inboundAllowlist.blockedAttempts(mailboxId);

// 4. Release a firewall_blocked message back into scanner processing.
await rl.messages.firewallRelease(messageId);
```

### Python

```python
from replylayer import ReplyLayer
rl = ReplyLayer(api_key=os.environ["REPLYLAYER_API_KEY"])

rl.inbound_blocklist.add(email="@spam-domain.com")
rl.mailboxes.inbound_allowlist.add(mailbox_id, email="finance@partner.com")
rl.mailboxes.set_sender_policy(mailbox_id, "allowlist")

# After ingest, a blocked message can be released:
rl.messages.firewall_release(message_id)
```

### CLI

```bash
# Block a sender account-wide.
rly inbound-blocklist add @spam.com
rly inbound-blocklist list

# Per-mailbox allowlist + mode flip.
rly mailbox inbound-allowlist add support finance@partner.com
rly mailbox set-sender-policy support allowlist

# Inspect blocked attempts (aggregated by default; --raw for per-attempt history).
rly mailbox inbound-allowlist blocked support
rly mailbox inbound-allowlist blocked support --raw --within-days 7

# Release a firewall_blocked message.
rly firewall-release <message-id>
```

### MCP (for agents)

Agents can drive the firewall over MCP: `add_inbound_blocklist` /
`list_inbound_blocklist`, `add_inbound_allowlist_entry` /
`list_inbound_allowlist`, `list_inbound_firewall_blocked_attempts`, and
`release_firewall_blocked_message`. As with `add_suppression`, the blocklist
*add* tool has no matching *remove* tool — undoing a block is done through the
CLI, SDK, or dashboard. See the [MCP reference](/docs/mcp) for the full tool set.

## Limits

- **5,000 entries added per hour, per account**, bucketed **separately** for the
  blocklist and the allowlist. The budget counts *entries added*, not requests — a
  1,000-row bulk add consumes 1,000. Exceeding it returns `429 RATE_LIMITED`.
- **Bulk add is capped at 1,000 entries per request.** Bulk adds return
  partial-success buckets (`added`, `already_existed`, `invalid`, `counts`) rather
  than failing the whole batch.

See the [error reference](/agents/errors) for the full catalog of codes these
endpoints can return.

## Webhooks

The firewall emits `inbound_sender.blocked` on each rejection, plus lifecycle
events on config changes (`sender_allowlist.added` / `.removed`,
`sender_blocklist.added` / `.removed`, and `mailbox.sender_policy_changed`). The
`inbound_sender.blocked` sender fields are redacted under `pii_mode='redacted'`;
the config events are exempt (you authored those addresses). A released message
then emits the usual message-lifecycle event for the scanner's verdict. See the
[webhook guide](/docs/webhooks) for payloads and the full
[event catalog](/agents/webhooks).

## Related

- [Recipient allowlist](/docs/guides/recipient-allowlist) — the outbound
  counterpart.
- [Do-not-contact list (suppressions)](/docs/guides/suppressions) — the outbound
  do-not-contact list.
- [Sub-addressing and secure replies](/docs/guides/sub-addressing) — the verified
  reply mechanism that drives the bypass.
- [Content scanning](/docs/guides/content-scanning) — what happens after a release.
