# Do-not-contact list (suppressions)

The suppression list is the gate every send goes through. If a recipient is on it,
the API returns `403 RECIPIENT_SUPPRESSED` (with `details.reason: 'suppressed'`)
before any message is sent. ReplyLayer auto-populates it from delivery signal
(hard bounces, spam complaints, RFC 8058 unsubscribes). You can also **add and
remove entries yourself** via the API, CLI, SDKs, MCP, or the dashboard.

## At a glance

| | Who can do it | Notes |
|---|---|---|
| **Add** (single + bulk) | Agent + admin keys, dashboard | Server forces `reason='manual'` + `source='customer'`. Idempotent. |
| **List** (paginated) | Any account-scoped key, dashboard | Default 500 rows + cursor; `?all=true` capped at 10,000. |
| **Remove** | **Admin keys + dashboard only** (agent keys → 403) | Agents can add; only humans can lift the block. |
| **Webhook** | `recipient_blocklist.added` + `recipient_blocklist.removed` | Address is the operator signal — payloads are exempt from PII redaction. |

Rate limit on the add endpoints: **5,000 emails added per hour per account**. It
counts emails added, not requests made — a 1,000-row bulk request consumes 1,000
against the budget.

## Why a customer would add an address

- **A user told you "stop emailing me"** outside the unsubscribe-link path. Block
  them so even an LLM agent that re-attempts can't slip a follow-up through.
- **Legal / GDPR / CCPA right-to-erasure**: bulk-import affected addresses (up to
  1,000 per request) so they're rejected at send time across every code path.
- **Migrating from another email platform**: import your existing suppression list
  before you point traffic at ReplyLayer.
- **Test recipients you no longer want hit during development**: block them so a
  stale config can't accidentally email them.

## Reasons (the `reason` enum)

| Value | Source | Customer-removable? |
|---|---|---|
| `hard_bounce` | Delivery-provider bounce event | Admin-only (deliverability footgun) |
| `complaint` | Delivery-provider spam-complaint event | Admin-only (same) |
| `unsubscribe` | RFC 8058 one-click + `mailto:` reply | Admin-only |
| `manual` | Customer add (this surface) or admin | Admin-removable |

Customers who add via `POST /v1/suppressions` always get `reason='manual'` +
`source='customer'`. The API rejects requests that try to inject any other
`reason`.

## Reading the actor fields (`added_by_actor_type`, `added_by_actor_id`)

Every row carries first-class actor metadata so you can answer "who blocked this?"
directly from the read response:

| `added_by_actor_type` | `added_by_actor_id` | Meaning |
|---|---|---|
| `admin` | API key id | Added via an admin-role API key |
| `agent` | API key id | Added via a mailbox-scoped agent key |
| `user` | account id | Added via the dashboard (session auth) |
| `system` | source string (e.g. `provider-webhook`, `mailto-unsubscribe`, `one-click-https`) | Added by ReplyLayer in response to a delivery event or unsubscribe |
| `null` | `null` | Legacy row that predates first-class actor capture |

## Quickstart

### TypeScript

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

// Single add — idempotent
const res = await rl.suppressions.add({ email: 'stop@example.com' });
if (res.already_existed) {
  console.log(`${res.email} was already on the list`);
} else {
  console.log(`Added ${res.email} (added_by ${res.added_by_actor_type})`);
}

// Bulk add (up to 1000 per request)
const bulk = await rl.suppressions.addBulk({
  emails: ['user1@example.com', 'User2@Example.com', 'not-an-email'],
});
console.log(`Added ${bulk.counts.added}, already-on-list ${bulk.counts.already_existed}, invalid ${bulk.counts.invalid}`);
for (const bad of bulk.invalid) {
  console.warn(`  ${bad.email}: ${bad.reason}`);
}

// List (paginated; the default cap is 500 — use `all: true` up to 10 000)
const page = await rl.suppressions.list({ limit: 100 });
for (const row of page.suppressions) {
  console.log(row.email, row.reason, row.added_by_actor_type ?? 'legacy');
}
if (page.next_cursor) {
  const next = await rl.suppressions.list({ limit: 100, cursor: page.next_cursor });
  // ...
}
```

### Python

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

res = rl.suppressions.add(email="stop@example.com")
print(f"already_existed={res['already_existed']}")

bulk = rl.suppressions.add_bulk(emails=["a@x.com", "B@X.com", "not-an-email"])
print(f"added={bulk['counts']['added']} invalid={bulk['counts']['invalid']}")

page = rl.suppressions.list(limit=100)
while True:
    for row in page["suppressions"]:
        print(row["email"], row["reason"], row.get("added_by_actor_type") or "legacy")
    if not page["next_cursor"]:
        break
    page = rl.suppressions.list(limit=100, cursor=page["next_cursor"])
```

### CLI

```bash
rly suppressions list                      # default 100/page
rly suppressions list --reason manual      # filter
rly suppressions list --all                # paginate everything
rly suppressions add stop@example.com      # idempotent
rly suppressions remove stop@example.com   # admin-only; agent keys → 403
```

### MCP (for agents)

Two tools are exposed: `list_suppressions` (for "before I send, am I sure this
isn't blocked?") and `add_suppression` (for "the user asked me to stop").
**Removal is intentionally not exposed via MCP** regardless of key scope — undoing
a "stop contacting" decision is a per-human call. Operators run the CLI/SDK or use
the dashboard with an admin key.

## Domain entries

Suppression entries can be either exact emails (`alice@competitor.com`) or
bare-domain patterns (`@competitor.com`) that block every address at that domain.
Mix both forms freely — the pre-send gate checks both in a single query.

**Match semantics (exact-domain only, no wildcards):**

| Entry | Blocks | Does NOT block |
|-------|--------|----------------|
| `alice@competitor.com` | `alice@competitor.com` only | everyone else |
| `@competitor.com` | any `*@competitor.com` | `eve@sub.competitor.com` — add `@sub.competitor.com` explicitly |

**Response shapes:** every suppression read/write surface exposes a derived
`pattern_type` field — `"email"` or `"domain"`. Older clients that predate the
field will see it absent; the SDK types treat it as optional.

```typescript
// TS SDK — block a whole domain.
await rl.suppressions.add({ email: '@competitor.com' });

// Bulk mix.
const bulk = await rl.suppressions.addBulk({
  emails: ['alice@competitor.com', '@spam.example', 'not-an-email'],
});
// bulk.added[0].pattern_type === 'email'
// bulk.added[1].pattern_type === 'domain'
// bulk.invalid[0]                 === { email: 'not-an-email', reason: 'invalid_format' }
```

```python
# Python SDK.
res = rl.suppressions.add(email="@competitor.com")
assert res["pattern_type"] == "domain"
```

```bash
# CLI — add command accepts either form; list output shows a TYPE column.
rly suppressions add @competitor.com
rly suppressions list
#   ENTRY              TYPE    REASON   SOURCE    ADDED                ADDED BY
#   alice@spam.com     email   manual   customer  2026-04-19T18:00:00Z admin
#   @competitor.com    domain  manual   customer  2026-04-19T19:02:00Z admin
```

**Validation rules** (shared with the allowlist parser):

- Trim + lowercase before validation.
- Domain patterns must be multi-label ASCII: `@corp.com`, `@my-company.io`,
  `@sub.example.co.uk` ✓
- Rejected: `@`, `@.com`, `@foo`, `@foo.`, `@.foo.com`, `@foo..com`, `@-corp.com`,
  `@_foo.com`, any non-ASCII.
- Length caps: total domain ≤ 253 chars; each label ≤ 63 chars.

**Domain-level blocks are a ReplyLayer construct.** Delivery providers only track
exact addresses, so deleting a `@domain.com` entry always removes cleanly without
provider round-trips.

**Webhook subscribers:** the `address` field in `recipient_blocklist.added` /
`recipient_blocklist.removed` events may carry `@domain.com` strings. Handle both
forms.

**Provider-driven writes remain exact-email-only:** hard-bounce + complaint events
and one-click/mailto unsubscribes always write the exact bouncing/complaining
address — they never produce `@domain.com` rows. Only customer-initiated adds
(POST routes, CLI, SDK, MCP, dashboard) can create domain patterns.

## Idempotency, normalization, and case-variant dedupe

- The server normalizes every email: `trim()` + `toLowerCase()`. `Foo@Bar.com` and
  `foo@bar.com` are the same row.
- A repeat add of the same email returns `already_existed: true` with
  `created_at: null`. **No** second audit row, **no** second webhook delivery.
  Concurrent adds of the same address resolve safely to a single row.
- `addBulk` applies the same normalization, then dedupes the input array before
  validation. `counts.total` is the deduped size, not the raw input length.

## Rate limiting

The add endpoints share a per-account hourly budget of 5,000 emails added. When
tripped:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 2734
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1745000000

{ "error": "Too many requests", "code": "RATE_LIMITED",
  "details": { "retry_after": 2734 } }
```

Both SDKs honor `Retry-After` up to ~67 minutes so a client can ride out an hour
bucket instead of throwing. Mutating retries on the add endpoints are still gated
to "server rejected, nothing happened" semantics — you won't get duplicate inserts
from a retried 429.

## Webhook events

`recipient_blocklist.added` and `recipient_blocklist.removed` fire on the
corresponding mutations. Payload shape:

```json
{
  "id": "<event-uuid>",
  "event": "recipient_blocklist.added",
  "account_id": "<account-uuid>",
  "occurred_at": "2026-04-17T18:30:00.000Z",
  "data": {
    "address": "stop@example.com",
    "reason": "manual",
    "added_at": "2026-04-17T18:30:00.000Z"
  }
}
```

`recipient_blocklist.removed` payloads use `removed_at` instead of `added_at`. Both
events are exempt from delivery-time PII redaction even when the mailbox has
`pii_mode='redacted'` — the address is the operator signal, and redacting it would
defeat the event's purpose.

## Troubleshooting

**"This recipient cannot receive email" / 403 RECIPIENT_SUPPRESSED on send**
The recipient is on the suppression list. Run
`rly suppressions list --reason hard_bounce` (or `--reason complaint`) to confirm,
and check the `added_by_actor_*` columns to understand why. Manually-added entries
can be removed by an admin; bounce/complaint rows should generally stay (lifting
them risks domain-reputation damage).

**`429 RATE_LIMITED` on bulk add**
You hit the entry-count budget for the hour. Wait the `Retry-After` window. If you
have a one-time import bigger than the bucket, contact support to raise the limit
for your account.

**Agent key got `INSUFFICIENT_SCOPE` on remove**
By design — only admin keys (and dashboard sessions) can remove suppressions. The
CLI surfaces this with a hint pointing at the right key.
