Inbound firewall (sender allowlist + blocklist)

The inbound firewall is the symmetric counterpart to the outbound recipient allowlist and suppression list: 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

ActionWho can do itNotes
Add to the account-wide blocklistAdmin + agent keys, dashboardServer forces reason='manual', source='customer'. Idempotent.
Add to a mailbox's allowlistAdmin + agent keys, dashboardDiverges from the outbound allowlist (which is admin-only) — see Why mutations are admin + agent.
Remove from the blocklistAdmin + agent keys, dashboardDiverges from outbound suppressions (admin-only remove).
Remove from an allowlistAdmin + agent keys, dashboardRemoving the last entry while in allowlist mode returns 409 INBOUND_ALLOWLIST_LAST_ENTRY; override with ?force_empty=true.
Flip a mailbox's modeAdmin + agent keys, dashboardPATCH /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 messageAdmin + agent keys (mailbox-bound), dashboardPOST /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 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 ingestThe account-wide blocklistThe account-wide blocklist first, then the mailbox's allowlist
Who's rejectedSenders on the blocklistSenders not on the allowlist (or on the blocklist)
ScopeAccount-widePer mailbox
firewall_block.reason_codeSENDER_BLOCKEDSENDER_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 ([email protected]) 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 [email protected]. 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 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):

"firewall_block": {
  "envelope_sender": "[email protected]",
  "from_address": "[email protected]",
  "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.

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 for what each verdict means and 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

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: '[email protected]' });
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

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="[email protected]")
rl.mailboxes.set_sender_policy(mailbox_id, "allowlist")

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

CLI

# 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 [email protected]
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 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 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 for payloads and the full event catalog.