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
| 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. |
| 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 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
allowlistmode so only known senders (vendors, internal automation) can reach it; everyone else lands infirewall_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 FROMaddress) and the parsedFrom:header. A match on either one rejects the message. Thefirewall_block.matched_fieldvalue tells you which matched (envelopeorfrom). - 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.comdoes not catch[email protected]. Add an explicit@sub.spam.comentry for each sub-domain. - Matching is deterministic — when several patterns match, most-specific wins
(an exact email beats a
@domainpattern); 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@domainonly.
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 afirewall_blockobject 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 omitfirewall_blockedrows so integrations don't silently start seeing a new state. Pass?include_firewall_blocked=trueon any of them to opt back in (the dashboard's Firewall tab does this). - Does appear on a single-message fetch —
GET /v1/messages/:idalways 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 infirewall_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.
Related
- Recipient allowlist — the outbound counterpart.
- Do-not-contact list (suppressions) — the outbound do-not-contact list.
- Sub-addressing and secure replies — the verified reply mechanism that drives the bypass.
- Content scanning — what happens after a release.