# Suppressions & Consent

Source: https://molted.email/docs/suppressions

Manage suppression lists, record consent, and enforce compliance across scopes.

Suppressions prevent email from being sent to specific recipients, and are always enforced automatically by the policy engine: for direct sends, journey steps, and scheduled follow-ups alike, plus a final re-check by the send worker immediately before the email goes out (covering sends that were scheduled or deferred for domain warmup, which can sit queued for up to 30 days).

Consent records track the legal basis for contacting someone, but by default that's all they do: recording consent does not by itself stop or allow anything. A tenant can opt into enforcement (see [Consent](#consent) below); until then, consent is a ledger you check yourself.

Recipient email matching covers every spelling of the same mailbox. A suppression on `tom@acme.com` also blocks:

- other case and surrounding whitespace: `Tom@ACME.com`
- a `+tag`, in either direction: `tom+news@acme.com` (and a suppression on `tom+news@acme.com` blocks `tom@acme.com`)
- dots in a Gmail or Googlemail local part: `t.o.m@gmail.com` is `tom@gmail.com`, and `tom@googlemail.com` is `tom@gmail.com` too
- a trailing dot on the domain, including a Unicode full stop: `tom@acme.com.`, `tom@acme.com。`
- an internationalized domain in Unicode or punycode form: `ida@bücher.example` is `ida@xn--bcher-kva.example`
- a quoted local part that needs no quotes: `"tom"@acme.com`
- a display-name form: `"Tom" <tom+x@acme.com>`

Dots in a local part at any other provider are significant, and a Unicode lookalike (a Cyrillic `о` in place of a Latin `o`) is a different address, so neither is matched. Domain suppressions match the domain in any case, with or without a trailing dot, in Unicode or punycode form.

| Task | CLI | MCP |
| - | - | - |
| Add an address | `molted suppressions add` | `add_suppression` (`recipient_email`) |
| Add a domain | `molted suppressions add-domain` | `add_suppression` (`domain`) |
| Check before sending | `molted suppressions check` | `check_suppression` |
| List | `molted suppressions list`, `list-domains` | `list_suppressions` (`kind`: `email` or `domain`) |
| Remove (a person's decision) | `molted suppressions remove <id>`, `remove-domain <domain>` | `remove_suppression` |
| Bulk import | `molted suppressions import` | |
| Record or check consent | `molted consent record`, `molted consent check` | `record_consent`, `check_consent` |

Adding a suppression only tightens: a key with access to every mailbox needs the `write` scope, and a mailbox-scoped key with `send` or `manage` on at least one mailbox may do it too. Listing needs a key with access to every mailbox; removing needs `manage` on every mailbox and a person (see [Remove a suppression](#remove-a-suppression)). The check needs only a valid key.

## Suppression scopes

Suppressions can be applied at two levels from your tenant:

| Scope | Description |
| - | - |
| `tenant` | Blocked for your tenant. |
| `campaign` | Recorded against a campaign ID. |

> **Warning:** `campaign` scope is not yet limited to the named campaign ([#1932](https://github.com/chunkydotdev/molted-mail/issues/1932)): a `campaign` row currently blocks every send to that address from your tenant, the same as `tenant` scope. Real per-campaign scoping is tracked in [#1984](https://github.com/chunkydotdev/molted-mail/issues/1984).
>
> This also reaches list subscription, since both paths run the same suppression check: `POST /v1/agent/lists/:id/subscribers` (`molted lists subscribe`) now rejects a campaign-suppressed address with `400 Bad Request: Contact is suppressed: <reasonCode>`, and the public opt-in webhook path (`subscribe_to_list` inbound-hook action) silently does not subscribe a campaign-suppressed address (by design, the same as a tenant-suppressed one: it never reactivates a suppressed contact, with no error shown to the submitter). Remove every suppression row for an address (see [Remove a suppression](#remove-a-suppression)) before expecting it to subscribe again.

Platform-wide (`global`) suppressions also exist but are reserved for automated complaint and hard-bounce handling by Molted's webhook pipeline: they cannot be created by tenant API keys. Attempting to do so returns `403 global_scope_not_allowed`.

## Add a suppression

```bash title="CLI"
molted suppressions add --email user@example.com --reason manual_dnc --scope tenant
```

```bash title="CLI - with expiration"
molted suppressions add --email user@example.com --reason no_engagement --scope tenant \
  --expires-at 2027-01-01T00:00:00Z
```

### CLI flags

| Flag | Type | Required | Description |
| - | - | - | - |
| `--email` | `string` | Yes | Email address to suppress. Must be a valid email format. |
| `--reason` | `string` | Yes | Reason code (see table below). |
| `--scope` | `string` | Yes | Suppression scope: `tenant` or `campaign`. |
| `--campaign-id` | `string` | No | Campaign ID. Required when `--scope` is `campaign`. |
| `--source` | `string` | No | Source of the suppression (for your own tracking). |
| `--expires-at` | `string` | No | ISO 8601 expiry timestamp. If omitted, suppression is permanent. |

### Via the API

```
POST https://mailbox.molted.email/v1/suppressions
```

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/suppressions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipientEmail": "user@example.com",
    "scope": "tenant",
    "reasonCode": "manual_dnc",
    "expiresAt": "2027-01-01T00:00:00Z"
  }'
```

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `recipientEmail` | `string` | Yes | Email address to suppress. |
| `scope` | `string` | Yes | Suppression scope: `tenant` or `campaign`. |
| `reasonCode` | `string` | Yes | Reason code (see table below). |
| `campaignId` | `string` | No | Campaign ID. Required when `scope` is `campaign`. |
| `source` | `string` | No | Source of the suppression (for your own tracking). |
| `expiresAt` | `string` | No | ISO 8601 expiry timestamp. If omitted, suppression is permanent. |

Adding an address that is already suppressed only ever tightens it:

- A permanent suppression stays permanent, and one with an expiry keeps the later of the two. Only a suppression that has already expired takes the new `expiresAt`.
- A weaker reason never replaces a stronger one. A complaint, a hard bounce, an unsubscribe or a legal request outranks `manual_dnc`, `role_account` and `domain_suppressed`, which outrank `soft_bounce` and `no_engagement`. The reason and `source` change only when the new reason ranks at least as high.
- The `source` value `portal_keep_away` is reserved for a person's **Keep agents away** in the portal; sending it returns `400 reserved_source`.

Lifting a suppression early is a removal (below), which needs a person.

### Reason codes

| Code | Description |
| - | - |
| `complaint` | Recipient marked an email as spam. |
| `hard_bounce` | Email address hard-bounced. |
| `manual_dnc` | Manually added to do-not-contact list. |
| `legal_request` | Suppressed due to a legal request (e.g., GDPR erasure). |
| `role_account` | Role account (e.g., `info@`, `admin@`) that should not receive automated email. |
| `domain_suppressed` | Entire domain is suppressed. |
| `no_engagement` | Contact has not engaged with prior emails. Consider using `--expires-at` for temporary suppression. Not accepted for a domain suppression. |

Molted itself also writes rows with reasons you can't set, such as `unsubscribe_link` and `unsubscribe_request` (a recipient unsubscribed) and `soft_bounce`. They show up in lists and checks like any other.

## Suppress a whole domain

```bash title="CLI"
molted suppressions add-domain --domain competitor.com --reason manual_dnc
```

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/suppressed-domains \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "domain": "competitor.com", "reasonCode": "manual_dnc" }'
```

`domain` must be a plain domain name (`400` otherwise). `reasonCode` is one of the codes above except `no_engagement`, and `source` is optional. A domain suppression is always tenant-wide, never expires on its own, and blocks every address at that domain.

## Remove a suppression

```bash title="CLI"
molted suppressions remove SUPPRESSION_ID
molted suppressions remove-domain competitor.com
```

```bash title="curl"
curl -X DELETE "https://mailbox.molted.email/v1/suppressions/SUPPRESSION_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"

curl -X DELETE "https://mailbox.molted.email/v1/suppressed-domains/competitor.com" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Both answer `{ "deleted": true }`. Removing a suppression loosens a compliance control, so from an agent key it is a human decision: in `enforce` mode it returns `403 human_decision_required` with a `decisionUrl` and removes nothing, in `warn` mode it goes through with a `Molted-Deprecation: human-gate` header. A person signed in to the portal is never gated. See [Loosening a control](https://molted.email/docs/errors.md#loosening-a-control). A mailbox-scoped key gets `403 mailbox_scope_denied`.

This removes one row. An address can have more than one (a tenant-scoped row plus one or more campaign-scoped rows, all enforced the same way, [#1932](https://github.com/chunkydotdev/molted-mail/issues/1932)): removing this id does not mean the contact can receive email from you again if another row for it still exists. A row for a variant spelling of the address (for example `tom+news@acme.com`) also still blocks `tom@acme.com`, and is listed under its own spelling. List every row for the address first ([List suppressions](#list-suppressions)), remove each one, then confirm with [Check a recipient](#check-a-recipient) before sending.

## Check a recipient

Ask whether a send to an address would be blocked. The answer comes from the same check the policy engine runs on every send, with the same inputs: email and domain suppressions, your tenant's rows and platform-wide ones, and expiry. So an expired suppression reads as clear, a domain suppression counts for every address at that domain, and a campaign-scoped suppression blocks the same as a tenant-scoped one ([#1932](https://github.com/chunkydotdev/molted-mail/issues/1932)).

If the lookup itself fails, the check answers `503 suppression_check_unavailable` rather than "clear". Don't send until it answers.

```bash title="curl"
curl "https://mailbox.molted.email/v1/suppressions/check?email=user@example.com" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```bash title="CLI"
molted suppressions check --email user@example.com
molted suppressions check --domain competitor.com
```

```json title="Response"
{ "email": "user@example.com", "suppressed": true, "reason": "manual_dnc", "matchedOn": "domain" }
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `email` | `string` | One of `email` or `domain` | Address to check. |
| `domain` | `string` | One of `email` or `domain` | Domain to check: is every address at it suppressed? |

`reason` is the matching suppression's reason code and `matchedOn` is `email` or `domain` (both `null` when the address is clear). Passing neither or both of `email` and `domain`, or either one twice, returns `400 invalid_params`.

## List suppressions

```bash title="CLI"
molted suppressions list --email user@example.com
molted suppressions list --limit 50 --offset 100
molted suppressions list-domains --limit 50 --offset 100
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/suppressions?recipientEmail=user@example.com" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

| Parameter | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `recipientEmail` | `string` | No | Filter by email address. |
| `limit` | `integer` | No | Page size, 1-500. With neither `limit` nor `offset` the list returns every row for `recipientEmail`, or the latest 100. |
| `offset` | `integer` | No | Rows to skip, 0-1,000,000. |

Rows come back newest first. A listed row doesn't always block a send (it may be expired); use [Check a recipient](#check-a-recipient) for that. `GET /v1/suppressed-domains` (`molted suppressions list-domains`) takes the same `limit` and `offset` (without them it returns every domain suppression). The MCP `list_suppressions` tool always pages, and returns a `nextOffset` for the next page.

## Bulk import

Import suppressions in bulk: useful when migrating bounces or unsubscribes from another provider.

```bash title="CLI"
molted suppressions import --file bounces.csv
# CSV header: email,scope,reasonCode,campaignId,source,expiresAt
# (campaignId, source, expiresAt are optional columns)

molted suppressions import --file bounces.json
# JSON array: [{"email":"a@x.com","scope":"tenant","reasonCode":"hard_bounce"}, ...]

molted suppressions import --file bounces.csv --dry-run      # validate only, send nothing
# exits 1 if any row is invalid (unless --skip-invalid is also given -- then a real run would succeed too)
molted suppressions import --file bounces.csv --skip-invalid # import the valid rows, report the rest
```

Every row is validated client-side first (email format, `scope` `tenant`/`campaign` (`global` is rejected and `campaignId` is required for `campaign`), `reasonCode` from the table above, `expiresAt` must parse as a date). By default any invalid row aborts the whole import and sends nothing; pass `--skip-invalid` to import the valid rows anyway. Rows are sent to the API in chunks of up to 500 rows or \~80KB of JSON, whichever comes first.

Caveat ([#1932](https://github.com/chunkydotdev/molted-mail/issues/1932)): a campaign-scoped suppression (`scope: "campaign"`) is recorded against the campaign ID, but currently enforced the same as `scope: "tenant"`: it blocks every send to that address from your tenant, not just the named campaign. Real per-campaign scoping is tracked in [#1984](https://github.com/chunkydotdev/molted-mail/issues/1984); until then `scope: "campaign"` and `scope: "tenant"` behave the same on send.

```
POST https://mailbox.molted.email/v1/ops/suppressions/bulk
```

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/ops/suppressions/bulk \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "entries": [
      { "recipientEmail": "a@example.com", "scope": "tenant", "reasonCode": "hard_bounce" },
      { "recipientEmail": "b@example.com", "scope": "tenant", "reasonCode": "manual_dnc", "source": "provider-migration" }
    ]
  }'
```

Response: `{ "added": 2, "results": [{ "id": "..." }, { "id": "..." }] }`.

This route has no server-side validation at all (not even that `recipientEmail` is present, or that `scope`/`reasonCode` are one of the accepted values), and each entry is inserted individually rather than in one transaction: an invalid or malformed entry partway through an unvalidated request can leave earlier entries committed. The CLI's `suppressions import` validates every row up front and chunks large files for exactly this reason; if you're calling the API directly, validate and chunk on your own side too.

---

## Consent

Consent records document the legal basis for contacting someone. By default this is just a ledger: recording consent (or a withdrawal) does not change what the policy engine does.

A tenant can opt into enforcement with the `requireConsentForMarketing` setting (`GET`/`PATCH /v1/agent/config/settings`, or `/v1/me/settings` from the portal session; default `false`):

```bash title="CLI - turn on consent enforcement"
molted settings update --require-consent-for-marketing true
```

Once it's on, the policy engine checks consent before every MARKETING-template send: missing or withdrawn consent blocks the send with reason `no_consent`. "Active" consent is the latest consent record for that recipient with no `revokedAt` set; a `revokedAt` in the future still counts as withdrawn right now, the same as one in the past. Transactional emails (receipts, password resets), `_default`, and `_`-bucket inline sends are always exempt, whatever the setting. Turning enforcement on tightens a tenant's controls (any agent key may do it); turning it off loosens them and is human-gated, the same as lowering the cooldown or clearing the physical address: see [Loosening a control](https://molted.email/docs/errors.md#loosening-a-control).

List sends and double opt-in confirmations are exempt too: a broadcast to a list (`POST /v1/agent/lists/:id/send`) and the confirmation email a double opt-in list sends when someone subscribes never check `consent_records`: the list subscription itself, with its own opt-in and its own unsubscribe, is the consent. This holds whatever `requireConsentForMarketing` is set to.

### Record consent

```bash title="CLI"
molted consent record --email alice@example.com \
  --type explicit_opt_in --source signup-form --jurisdiction EU
```

```bash title="CLI - revoke consent"
molted consent record --email alice@example.com \
  --type explicit_opt_in --revoked-at 2026-06-15T00:00:00Z
```

#### CLI flags

| Flag | Type | Required | Description |
| - | - | - | - |
| `--email` | `string` | Yes | Recipient email address. |
| `--type` | `string` | Yes | Legal basis (see table below). |
| `--source` | `string` | No | Where consent was collected (e.g., `signup-form`). |
| `--jurisdiction` | `string` | No | Jurisdiction code (e.g., `EU`, `US-CA`, `UK`, `BR`). Free-form, used for compliance reporting. |
| `--granted-at` | `string` | No | When consent was granted (ISO 8601). Defaults to now. |
| `--revoked-at` | `string` | No | When consent was revoked (ISO 8601). Set this to revoke existing consent. |

#### Via the API

```
POST https://mailbox.molted.email/v1/consent
```

```bash title="curl"
curl -X POST https://mailbox.molted.email/v1/consent \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipientEmail": "alice@example.com",
    "basis": "explicit_opt_in",
    "source": "signup-form",
    "jurisdiction": "EU"
  }'
```

```json title="Response"
{
  "id": "429c84fc-fd48-4c90-9506-2f4791398b2a",
  "tenantId": "tenant_abc123",
  "recipientEmail": "alice@example.com",
  "basis": "explicit_opt_in",
  "source": "signup-form",
  "jurisdiction": "EU",
  "grantedAt": "2026-04-20T17:30:00.000Z",
  "revokedAt": null,
  "createdAt": "2026-04-20T17:30:00.000Z"
}
```

Recording consent needs a key with the `write` scope and access to every mailbox; recording a withdrawal (`revokedAt` set) only tightens, so a mailbox-scoped key with `send` or `manage` may do it too. MCP: `record_consent`, and `check_consent` for the check below.

`grantedAt` defaults to the request time when omitted, so a freshly recorded record always has a usable timestamp for GDPR audit trails. Pass `grantedAt` explicitly only when backdating consent (e.g. importing history from another system). For a standalone revocation (where prior consent timestamp is unknown), pass `revokedAt` without `grantedAt` and the server preserves `grantedAt: null`.

### Legal bases

| Basis | Description |
| - | - |
| `explicit_opt_in` | Recipient explicitly agreed to receive email. Strongest basis, required for EU. |
| `legitimate_interest` | Business relationship justifies contact. |
| `contractual` | Email is necessary to fulfill a contract. |
| `legal_obligation` | Email is legally required. |

### Check consent status

Use `consent check` to verify whether you have a valid legal basis to send marketing email to a recipient.

```bash title="CLI"
molted consent check --email alice@example.com
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/consent?recipientEmail=alice@example.com" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

#### Response: consent exists

```json title="Response"
{
  "hasConsent": true,
  "basis": "explicit_opt_in",
  "source": "signup-form",
  "jurisdiction": "EU",
  "grantedAt": "2026-01-15T10:00:00Z"
}
```

If your tenant has `requireConsentForMarketing` on, marketing sends to this address are allowed. If it's off (the default), this is informational only: the send would go out either way.

#### Response: no consent

```json title="Response"
{
  "hasConsent": false
}
```

No consent record exists. If `requireConsentForMarketing` is on, marketing sends to this address are blocked by the policy engine with reason `no_consent`; transactional sends are unaffected either way.

To unblock marketing sends, record consent:

```bash
molted consent record --email alice@example.com \
  --type explicit_opt_in --source signup-form --jurisdiction EU
```

#### Response: consent revoked

```json title="Response"
{
  "hasConsent": false,
  "revokedAt": "2026-06-15T00:00:00Z"
}
```

Consent was previously granted but has been revoked. With `requireConsentForMarketing` on, marketing sends are blocked. To re-enable, record a new consent with a fresh legal basis.

### Consent behavior

- Consent is per-email address, not per-contact.
- Enforcement is opt-in and off by default: recording or revoking consent never blocks a send by itself. Turn it on with `molted settings update --require-consent-for-marketing true` (or `PATCH /v1/agent/config/settings`).
- Once enforcement is on, the policy engine checks consent on every marketing send: you do not need to call `consent check` manually before sending.
- `consent check` is useful for pre-flight validation in your agent logic, or for building UI that shows consent status, whether or not enforcement is on.
- Transactional templates bypass consent checks entirely.

### Related

- [GDPR & Privacy](https://molted.email/docs/privacy.md): data export, deletion, and decision trace
- [Sending Email](https://molted.email/docs/sending-email.md): how the policy engine evaluates send requests
