Suppressions

A suppression is a standing instruction to stop sending to one address. It sits in front of delivery: the worker checks the list before it hands a message to a remote server, and a match holds the send.

What a suppression is

Suppressions are the safety net for your reputation. An unsubscribe or a spam complaint keeps a bad address from receiving mail again, automatically and per tenant. The list is tenant data: the suppressionstable carries the same row-level security policy as messages, so every read and write runs inside the owning server's context and one server never sees another's list.

Each row records who is blocked, why, and how widely. The address is the blocked recipient, reason is an optional human-readable note (often the bounce diagnostic that prompted the entry), and stream_id sets the scope. See Sending email for the send path this gate guards and Message streams for the stream model it builds on.

Suppression types

The type column is a label for where the entry came from. It defaults to recipient in the schema, so a plain manual block lands as a recipient row. The other two values are written by the broadcast opt-out paths.

TypeWritten whenScope it lands with
recipientThe default. A manual block added through the API or dashboard.Server-wide.
unsubscribeA one-click unsubscribe on a broadcast stream.Stream-scoped to that broadcast stream.
complaintA spam complaint recorded through a feedback loop or by hand.Stream-scoped to the recipient's stream.

Scope: server-wide and stream-scoped

The stream_id column is what makes suppressions safe to use for marketing. It decides how far an entry reaches.

Scopestream_idEffect
Server-widenullBlocks the address on every stream on the server.
Stream-scopeda set stream_idBlocks the address on that one stream and leaves all other streams open.

The send gate expresses this in a single condition. An address is suppressed for a message when a row exists that is either server-wide or scoped to that message's own stream, where $2 is the message's stream_id:

the gate
SELECT count(*) FROM suppressions
 WHERE address = $1 AND (stream_id IS NULL OR stream_id = $2)

The payoff is practical. Someone who unsubscribes from your newsletter gets a suppression scoped to the broadcast stream, and their password-reset mail on the transactional stream still sends. A marketing opt-out stays inside marketing. Because an address can hold a server-wide row and one or more stream-scoped rows at once, the same address can be blocked everywhere and additionally opted out of a single stream.

How the worker holds a message

The delivery worker checks the list first thing when it picks up a queued message, passing the message's own stream_id so the scope rule applies. On a match it does three things:

  • Completes the queue entry, so the message is not retried.
  • Records a delivery with status Held and the note recipient is on the suppression list.
  • Fires the MessageHeld webhook.

The message never reaches a remote server. A held send is visible under the message in the dashboard and API with that delivery note, so it is easy to explain after the fact.

Complaints via feedback loops

Large mailbox providers offer feedback loops. When a recipient marks your mail as spam, the provider sends a complaint report in the ARF format (RFC 5965), an ordinary email whose body is a multipart/report with report-type=feedback-report. CamelMailer ingests these automatically: the worker recognises an ARF report by its envelope, independent of how it was routed, and hands it to the complaint path.

An abuse report that maps back to a broadcast recipient (through the List-Unsubscribe token embedded in the original message) becomes a complaint. That does double duty. It records a stream-scoped complaint suppression for the recipient, and it flips their subscription on that stream to unsubscribed. The suppression stops future sends on the stream, and the changed subscription removes the address from the stream's audience so a later campaign leaves them out. A report that parses but has nothing to action is recorded as processed and taken no further. A report that cannot be parsed is held with the parse error on the delivery, so a malformed report keeps the worker running. See Broadcast streams for the subscription model this updates.

The same complaint outcome is available directly on the Server API, and it is idempotent, so recording the same complaint twice is safe:

terminal
curl -s -X POST \
  "$API/api/v2/server/streams/newsletter/subscribers/reader@example.com/complaint" \
  -H "X-Server-API-Key: $SERVER_KEY"

Managing suppressions

Suppressions live on the Management (Admin) API under /api/v2/admin/..., authenticated with X-Admin-API-Key or a user session. Each server also has a Suppressions tab in the dashboard that lists every entry with its address, type badge, scope, reason, and date added. The scope column names the stream for a stream-scoped row and reads All streams for a server-wide one. Adding an address from the dashboard creates a server-wide recipient row, and the table exports to CSV.

List every entry, with the server's streams for naming scopes:

list
curl -s \
  "$API/api/v2/admin/organizations/acme/servers/production/suppressions" \
  -H "X-Admin-API-Key: $ADMIN_KEY"

Add one by POST to the same path. Only address is required. Entries added here are server-wide; stream-scoped rows come from the unsubscribe and complaint paths.

add
curl -s -X POST \
  "$API/api/v2/admin/organizations/acme/servers/production/suppressions" \
  -H "X-Admin-API-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"address": "bounced@example.com",
       "type": "recipient",
       "reason": "550 5.1.1 mailbox does not exist"}'

Delete is keyed on the address alone, so it clears every suppression for that address on the server, both the server-wide row and any stream-scoped ones. After deletion the address is deliverable again, so resolve the underlying bounce or complaint before you remove the entry.

delete
curl -s -X DELETE \
  "$API/api/v2/admin/organizations/acme/servers/production/suppressions/bounced@example.com" \
  -H "X-Admin-API-Key: $ADMIN_KEY"
Honest note on hard bounces. The delivery pipeline classifies every failure and grades a permanent 5xx reply as hard, so the category is recorded and the observability API can break failures down by it. This codebase does not turn that hard bounce into a standing suppression on its own. Promoting one is an operator action: add the address through the admin API or dashboard and it lands as a server-wide recipient row. The schema is built for exactly this case.