MOLTED EMAIL

Suppressions & Consent

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 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.

TaskCLIMCP
Add an addressmolted suppressions addadd_suppression (recipient_email)
Add a domainmolted suppressions add-domainadd_suppression (domain)
Check before sendingmolted suppressions checkcheck_suppression
Listmolted suppressions list, list-domainslist_suppressions (kind: email or domain)
Remove (a person's decision)molted suppressions remove <id>, remove-domain <domain>remove_suppression
Bulk importmolted suppressions import
Record or check consentmolted consent record, molted consent checkrecord_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). The check needs only a valid key.

Suppression scopes

Suppressions can be applied at two levels from your tenant:

ScopeDescription
tenantBlocked for your tenant.
campaignRecorded against a campaign ID.

campaign scope is not yet limited to the named campaign (#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.

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) 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

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

CLI flags

FlagTypeRequiredDescription
--emailstringYesEmail address to suppress. Must be a valid email format.
--reasonstringYesReason code (see table below).
--scopestringYesSuppression scope: tenant or campaign.
--campaign-idstringNoCampaign ID. Required when --scope is campaign.
--sourcestringNoSource of the suppression (for your own tracking).
--expires-atstringNoISO 8601 expiry timestamp. If omitted, suppression is permanent.

Via the API

POST https://mailbox.molted.email/v1/suppressions
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"
  }'
FieldTypeRequiredDescription
tenantIdstringNoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
recipientEmailstringYesEmail address to suppress.
scopestringYesSuppression scope: tenant or campaign.
reasonCodestringYesReason code (see table below).
campaignIdstringNoCampaign ID. Required when scope is campaign.
sourcestringNoSource of the suppression (for your own tracking).
expiresAtstringNoISO 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

CodeDescription
complaintRecipient marked an email as spam.
hard_bounceEmail address hard-bounced.
manual_dncManually added to do-not-contact list.
legal_requestSuppressed due to a legal request (e.g., GDPR erasure).
role_accountRole account (e.g., info@, admin@) that should not receive automated email.
domain_suppressedEntire domain is suppressed.
no_engagementContact 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

CLI
molted suppressions add-domain --domain competitor.com --reason manual_dnc
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

CLI
molted suppressions remove SUPPRESSION_ID
molted suppressions remove-domain competitor.com
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. 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): 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), remove each one, then confirm with 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).

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

curl
curl "https://mailbox.molted.email/v1/suppressions/check?email=user@example.com" \
  -H "Authorization: Bearer YOUR_API_KEY"
CLI
molted suppressions check --email user@example.com
molted suppressions check --domain competitor.com
Response
{ "email": "user@example.com", "suppressed": true, "reason": "manual_dnc", "matchedOn": "domain" }
ParameterTypeRequiredDescription
tenantIdstringNoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
emailstringOne of email or domainAddress to check.
domainstringOne of email or domainDomain 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

CLI
molted suppressions list --email user@example.com
molted suppressions list --limit 50 --offset 100
molted suppressions list-domains --limit 50 --offset 100
curl
curl "https://mailbox.molted.email/v1/suppressions?recipientEmail=user@example.com" \
  -H "Authorization: Bearer YOUR_API_KEY"
ParameterTypeRequiredDescription
tenantIdstringNoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
recipientEmailstringNoFilter by email address.
limitintegerNoPage size, 1-500. With neither limit nor offset the list returns every row for recipientEmail, or the latest 100.
offsetintegerNoRows 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 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.

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): 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; until then scope: "campaign" and scope: "tenant" behave the same on send.

POST https://mailbox.molted.email/v1/ops/suppressions/bulk
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 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):

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.

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.

CLI
molted consent record --email alice@example.com \
  --type explicit_opt_in --source signup-form --jurisdiction EU
CLI - revoke consent
molted consent record --email alice@example.com \
  --type explicit_opt_in --revoked-at 2026-06-15T00:00:00Z

CLI flags

FlagTypeRequiredDescription
--emailstringYesRecipient email address.
--typestringYesLegal basis (see table below).
--sourcestringNoWhere consent was collected (e.g., signup-form).
--jurisdictionstringNoJurisdiction code (e.g., EU, US-CA, UK, BR). Free-form, used for compliance reporting.
--granted-atstringNoWhen consent was granted (ISO 8601). Defaults to now.
--revoked-atstringNoWhen consent was revoked (ISO 8601). Set this to revoke existing consent.

Via the API

POST https://mailbox.molted.email/v1/consent
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"
  }'
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.

BasisDescription
explicit_opt_inRecipient explicitly agreed to receive email. Strongest basis, required for EU.
legitimate_interestBusiness relationship justifies contact.
contractualEmail is necessary to fulfill a contract.
legal_obligationEmail is legally required.

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

CLI
molted consent check --email alice@example.com
curl
curl "https://mailbox.molted.email/v1/consent?recipientEmail=alice@example.com" \
  -H "Authorization: Bearer YOUR_API_KEY"
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
{
  "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:

molted consent record --email alice@example.com \
  --type explicit_opt_in --source signup-form --jurisdiction EU
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 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.