# Suppression Do-not-contact suppression list + opt-in proof (GDPR/CAN-SPAM), hash-keyed and channel-aware, as a Bijection component - Package: @bijectionhq/suppression - Category: Messaging - Status: Preview - License: MIT - Bijection source (repository access required): https://github.com/bijectionhq/suppression/tree/b3b94194dfe96ed5b912895ead046738cee8069b - Upstream: https://github.com/vllnt/convex-suppression/tree/d5294fae01d9deec19c89ab99a2414a1b8ddf949 - Documentation: https://docs.bijection.com/components/using - Directory: https://bijection.com/components/suppression ## Get the source Bijection repository access is required. Request access at https://bijection.com/contact. ```sh git clone https://github.com/bijectionhq/suppression.git cd suppression git checkout b3b94194dfe96ed5b912895ead046738cee8069b ``` ## Before using this component Read the package README at the recorded revision for setup, dependencies, and API examples. Upstream documentation describes the original package and may differ from the Bijection adaptation. This is a source candidate for evaluation. Catalog inclusion does not establish npm availability or runtime qualification. Check compatibility with your SDK and deployment before adoption. External providers run their own compute. ## Upstream package documentation Recorded 2026-10-23. This describes @vllnt/convex-suppression; check the Bijection adaptation separately. @vllnt/convex-suppression stores do-not-contact decisions and opt-in evidence keyed by a contact hash supplied by your backend. A send eligibility check lets different outbound workflows consult the same suppression state. suppress records a channel-specific or global decision. recordOptIn stores evidence for a list, and isEligible combines suppression with an optional opt-in requirement. Repeated writes update the same key rather than creating duplicate rows. Your backend normalizes and hashes contacts, authorizes changes, and decides retention and messaging policy. Stored hashes and proof still need appropriate privacy handling. The component provides data and checks; it does not send messages or establish legal compliance by itself. Maintained by [bntvllnt](https://bntvllnt.com) at [VLLNT](https://vllnt.com). Find the author on [GitHub](https://github.com/bntvllnt) and [X](https://x.com/bntvllnt). ### Use cases - Email products: block sends after an unsubscribe, bounce, or complaint. - Mobile messaging: apply channel-specific suppression to SMS or push campaigns. - Multi-provider systems: keep suppression decisions when changing delivery providers. - Account erasure flows: retain an appropriate suppression record without depending on the deleted account row. ### How it works suppress records a channel-specific or global decision. recordOptIn stores evidence for a list, and isEligible combines suppression with an optional opt-in requirement. Repeated writes update the same key rather than creating duplicate rows. Your backend normalizes and hashes contacts, authorizes changes, and decides retention and messaging policy. Stored hashes and proof still need appropriate privacy handling. The component provides data and checks; it does not send messages or establish legal compliance by itself. ### Upstream README # @vllnt/convex-suppression The do-not-contact suppression list + opt-in proof, as a Convex component. ```ts const dnc = new Suppression(components.suppression); await dnc.suppress(ctx, contactHash, "complaint", { channel: "email" }); const canSend = await dnc.isEligible(ctx, contactHash, { channel: "email" }); ``` The GDPR opt-out / CAN-SPAM primitive: a global do-not-contact list keyed by an opaque **`contactHash`** (never raw PII, so it survives erasure) plus an opt-in proof ledger. Before every send the host asks `isEligible`; an unsubscribe / bounce / complaint calls `suppress`; a double-opt-in confirmation calls `recordOptIn`. Domain-neutral — channels and reasons are the host's. ## Features - **Hash-keyed, erasure-surviving** — stores only the opaque `contactHash`, never raw PII, so a tombstone outlives erasure of the subject. - **Channel-aware** — scope a suppression to one channel (`"email"`/`"sms"`/`"push"`), or omit `channel` for a global all-channel tombstone. - **One send gate** — `isEligible` answers `¬suppressed [∧ confirmed]` in one call; suppression always blocks, opt-in is per call. - **Opt-in proof ledger** — `recordOptIn` / `getOptInProof` store legal proof-of-consent per list, kept separate from any authz store. - **Idempotent** — re-suppressing / re-recording on its key tuple updates in place, so a replayed webhook never duplicates a row. - **Typed, opaque proof** — `Suppression` with an optional `proofValidator` narrows the stored evidence at the boundary. - **Server-sourced time** — `createdAt`/`confirmedAt` are stamped from the server clock; a caller can never supply a timestamp. - **Mount-safe** — correct under multiple named `app.use` mounts (e.g. marketing vs. transactional lists), each an isolated sandbox. ## Installation ```bash pnpm add @vllnt/convex-suppression ``` Peer dependency: `convex@^1.41.0`. ## Usage ```ts // convex/convex.config.ts import { defineApp } from "convex/server"; import suppression from "@vllnt/convex-suppression/convex.config"; const app = defineApp(); app.use(suppression); export default app; ``` ```ts // convex/email.ts — host owns auth + hashing; pass an opaque contactHash in. import { components } from "./_generated/api"; import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { Suppression } from "@vllnt/convex-suppression"; const dnc = new Suppression<{ ip: string }>(components.suppression, { proofValidator: v.object({ ip: v.string() }).parse, }); const hash = (email: string) => myHash(email.trim().toLowerCase()); // host's hashing + salt policy // The send gate — call before every send. export const canEmail = query({ args: { email: v.string() }, handler: (ctx, { email }) => dnc.isEligible(ctx, hash(email), { channel: "email", listKey: "newsletter", requireOptIn: true }), }); // A provider webhook suppresses on complaint; a confirmed double opt-in records proof. export const onComplaint = mutation({ args: { email: v.string() }, handler: (ctx, { email }) => dnc.suppress(ctx, hash(email), "complaint", { channel: "email" }), }); export const confirmOptIn = mutation({ args: { email: v.string(), ip: v.string() }, handler: (ctx, { email, ip }) => dnc.recordOptIn(ctx, hash(email), { listKey: "newsletter", source: "double-opt-in", proof: { ip } }), }); ``` Client options: `new Suppression(component, { proofValidator? })`. Omitting `channel`/`listKey` targets the global entry. ## API Reference | Method | Kind | Result | |--------|------|--------| | `suppress(ctx, contactHash, reason, opts?)` | mutation | `null` (`reason`: `"unsubscribe" \| "bounce" \| "complaint" \| "manual" \| "global"`; `opts`: `{ channel? }`) | | `unsuppress(ctx, contactHash, channel?)` | mutation | `boolean` (`true` if an entry was removed) | | `recordOptIn(ctx, contactHash, opts)` | mutation | `null` (`opts`: `{ listKey?; source; proof? }`) | | `isSuppressed(ctx, contactHash, channel?)` | query | `SuppressionView \| null` | | `getOptInProof(ctx, contactHash, listKey?)` | query | `OptInProofView \| null` | | `isEligible(ctx, contactHash, opts?)` | query | `boolean` (`opts`: `{ channel?; listKey?; requireOptIn? }`) | Full reference: [docs/API.md](docs/API.md). ## React Backend-only — no `./react` entry. The consumer surface is a server-side `isEligible` gate and webhook-driven `suppress` writes; a reactive do-not-contact badge, if ever needed, is an ordinary `useQuery` over the host's re-exported `isSuppressed` ref. ## Security - **Auth-agnostic** — the host authenticates the caller, decides who may suppress/query a hash, and passes an opaque `contactHash`; tables are sandboxed. - **Hash-keyed, never raw PII** — the host hashes and normalizes the contact and owns the salt policy; a suppression survives erasure of the subject. - **Server-sourced time** — `createdAt`/`confirmedAt` come from `Date.now()` in each handler, never the caller; the opt-in `proof` is opaque, narrowed by the host validator. See [docs/API.md](docs/API.md). ## Testing ```bash pnpm test # single run pnpm test:coverage # enforced 100% on covered files ``` Tests run against the real component runtime via `convex-test` (`@edge-runtime/vm`), not mocks. ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). ## Author Built by [bntvllnt](https://github.com/bntvllnt) · [bntvllnt.com](https://bntvllnt.com) · [X @bntvllnt](https://x.com/bntvllnt) Part of the [@vllnt](https://github.com/vllnt) Convex component fleet — [vllnt.com](https://vllnt.com) If this is useful, [sponsor the work](https://github.com/sponsors/bntvllnt). ## License MIT — see [LICENSE](LICENSE).