> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Suppressions and Preference Center

> After this ships, one click stops the messages a member does not want, and a bounced address is never emailed twice.

## Context

Two mechanisms that look similar and are not, and conflating them is a real bug.

**Consent** is what the person chose, and it lives in `core`. **Suppression** is what the system or
the provider imposed: a hard bounce, a spam complaint, an unsubscribe click. A person can consent
and still be suppressed — the address simply does not work — and a suppressed address must stay
suppressed even for transactional messages, because a bounce does not become deliverable because
the message is important.

The preference center is the third layer: within what they consented to, a member chooses the
granularity. Making it one click from any marketing message is not a courtesy; a hard-to-find
unsubscribe is what generates spam complaints, and complaints damage the shared sending reputation
of every tenant.

## Scope *(normative)*

* `messaging.suppressions`: global per tenant, per channel identifier.
* Automatic suppression on hard bounce, complaint and unsubscribe.
* `messaging.preference_settings`: per member, per channel, per category.
* One-click unsubscribe from any marketing message, including the list-unsubscribe header.
* Quiet hours and frequency caps configuration at tenant and campaign level.
* Suppression survival through erasure as a hashed identifier.

## Non-scope *(normative)*

* Consent, which belongs to `core` and is read here.
* Manual suppression import from a previous provider — a later import feature, though the table
  supports it.
* Re-engagement of suppressed addresses. A suppression is only lifted by the member acting.

## Behaviour *(normative)*

1. A suppression is keyed by **(tenant, channel, identifier)** — the email address or push token,
   not the contact. If the same address belongs to two contacts, both are suppressed, because the
   address is what bounced.
2. **Every send checks suppressions, no exceptions**, including transactional (FS-MSG-0001 rule 3).
3. Hard bounces and complaints suppress **automatically**, in the same transaction as the status
   (FS-MSG-0003). Soft bounces do not: a full mailbox is temporary.
4. Unsubscribe is **one click, no login**, from a signed link that identifies the member without
   authenticating them. Requiring a login to unsubscribe generates complaints.
5. The `List-Unsubscribe` and `List-Unsubscribe-Post` headers are set on every marketing message, so
   a mail client's own button works.
6. A suppression can be lifted **only by the member** re-subscribing, never by the tenant. A tenant
   un-suppressing an address that complained is exactly the behaviour that gets a domain blocked.
7. The preference center is **per channel and per category**. Transactional appears as informational
   and cannot be switched off (DEC-E3).
8. Quiet hours default at tenant level with campaign override; frequency caps apply to marketing
   only (DEC-E5).
9. Erasure deletes the profile but the suppression **survives as a hashed identifier** with no
   profile attached (DEC-J3).
10. FORBIDDEN: a tenant-facing endpoint that deletes a suppression · a marketing message without a
    working unsubscribe · counting transactional against a frequency cap.

## Data *(normative)*

| Table                           | Key invariants                                                                                                                                                  |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messaging.suppressions`        | unique (`tenant_id`, `channel_code`, `identifier_hash`); `reason` bounce\|complaint\|unsubscribe\|manual; `source`; `created_at`; append-only, survives erasure |
| `messaging.preference_settings` | (`tenant_id`, `contact_id`, `channel_code`, `category`); `is_enabled`; `updated_at`; transactional rows are read-only                                           |

The identifier is stored hashed so a suppression list is not a usable contact list.

## API *(normative)*

| Endpoint                                       | Class      | Permission                      | Budget       |
| ---------------------------------------------- | ---------- | ------------------------------- | ------------ |
| `GET /v1/messaging/suppressions`               | Management | `messaging.suppressions.read`   | p95 \<1 s    |
| `POST /v1/messaging/suppressions`              | Management | `messaging.suppressions.create` | p95 \<1 s    |
| `GET /unsubscribe/{token}`                     | Runtime    | signed token, no login          | p95 \<300 ms |
| `POST /unsubscribe/{token}`                    | Runtime    | signed token, no login          | p95 \<300 ms |
| `GET/PUT /v1/messaging/members/me/preferences` | Runtime    | member token                    | p95 \<300 ms |

The unsubscribe route lives at the root, outside `/v1`, because it is a link in an email that must
stay short, stable and human-readable. Documented exception to DEC-D1, like the OAuth routes.

## Events *(normative)*

None beyond the status events FS-MSG-0003 emits. A suppression is a consequence, not an occurrence.

## Acceptance criteria *(normative)*

1. A suppressed address receives nothing, including transactional and including a different contact
   sharing that address.
2. A hard bounce suppresses in the same transaction; a soft bounce does not.
3. Unsubscribe works from the signed link without a login, in one request.
4. `List-Unsubscribe` headers are present on every marketing message and function in a real client.
5. No tenant-facing endpoint removes a suppression.
6. The preference center cannot switch off transactional.
7. Frequency caps count marketing only.
8. After erasure the suppression still blocks that address, with no profile attached.
9. **Negative:** the suppression list cannot be exported as usable contact data — identifiers are
   hashed.

## Execution

Single slice, synchronous command. The unsubscribe route is public and signed; preference reads are
served from the same cache as the cascade configuration.

## Open questions

| # | Question                                                                        | Decides | By              |
| - | ------------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Default marketing frequency cap — how many per week before it is a nuisance?    | Daniel  | before approval |
| 2 | Can a member re-subscribe themselves after a complaint, or only after a bounce? | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
