Emailing & Audiences
The emailing feature lets an organisation send HTML email campaigns to its own mailing lists.
This guide covers the recipient side — the Contact and Audience records and their
membership join. A campaign itself is a content-surface Task on the
email board lane (draft → approve → send); the recipient model below is what a campaign fans out
to.
The model
Section titled “The model”Contact who the organisation emails (consent-tracked)Audience a named listAudience ──< audience_members >── Contact many-to-many membership (ids only)-
Contact — an org-scoped record for someone on the organisation’s mailing list. Its defining field is consent / deliverability status (
subscribed | unsubscribed | bounced), not identity or billing. Email is normalized (trimmed + lowercased) and unique per org.userIdis a creator/audit stamp only — reads are scoped byorgId, so any member of the org manages the shared list. -
Audience — a named list (“Newsletter subscribers”, “Q3 leads”). Just a grouping; a campaign points at one
audienceId. -
Audience membership (
audience_members) — the many-to-many join between an audience and its contacts. A contact can be in several audiences; an audience has many contacts. The join stores ids only — resolving ids →Contacts is anAudienceServiceconcern, which keeps the two repositories decoupled. Deleting an audience drops its links (ON DELETE CASCADE); deleting a contact drops its links but not the other way around.Naming note: this is audience membership — never bare
Membership/member. In this codebasememberalready means the better-auth user ↔ organization row (RBAC). The two are unrelated.
How a Contact differs from what already exists
Section titled “How a Contact differs from what already exists”A Contact looks superficially like several existing “person” records but is deliberately none of them. The one-line distinction:
user= who logs in ·member= who belongs to an org ·customers= who we bill ·Contact= who we email.
| Existing record | What it models | Why a Contact is not it |
|---|---|---|
user (auth) | A platform login identity — password, sessions, email_verified; email is globally unique. | A contact never logs in and has no credentials. The same person can be a contact of two different orgs, so contact email is unique per org, not globally. Marketing recipients must not pollute the auth user table. |
member (auth org plugin) | The user ↔ organization join with an RBAC role. This is what “membership” means elsewhere. | Audience membership is contact ↔ list — no role, no user. Different concept, deliberately different name. |
customers (invoicing) | The org’s external billing clients — billing address, tax id, stripe_customer_id. See Invoicing & Customers. | Closest cousin (both are org-scoped, userId = audit stamp) and the pattern a Contact mirrors. But a customer models “who we bill”; a contact models “who we email”, keyed on consent. A customer may later also be a contact, but they are separate rows — a contact.customerId bridge is deliberately deferred. |
QuoteRequest.contactEmail (quotes) | A single inbound lead (one submission). | Not a reusable, list-managed, consent-tracked recipient. |
Consent is the point
Section titled “Consent is the point”status is not decoration — it gates delivery. A campaign sends only to subscribed members
(AudienceService.listSubscribedContacts); unsubscribed and bounced contacts are retained for
suppression and never emailed. This is the seam where unsubscribe handling and bounce processing
hang later.
Where it lives (hexagonal)
Section titled “Where it lives (hexagonal)”Pure domain, infrastructure-free — same shape as every other vertical:
| Layer | Location |
|---|---|
| Entities | packages/platform-domain/src/audiences/entities/{Contact,Audience}.ts |
| Ports | packages/platform-domain/src/audiences/ports/{IContactRepository,IAudienceRepository}.ts |
| Services | packages/platform-domain/src/audiences/services/{ContactService,AudienceService}.ts |
| In-memory adapters (tests) | packages/platform-domain/src/audiences/adapters/InMemory*.ts |
| Email (campaign) lane constants | packages/platform-domain/src/tasks/destinations/email.ts |
The email board lane is host-based, keyed on a dedicated EMAIL_PUBLISH_HOST
(email.viite.ai) — distinct from the blog lane’s viite.ai, so destinationKindForTask can tell a campaign
from a blog post by host alone.
Not yet built
Section titled “Not yet built”This guide documents the shipped recipient model. The campaign send path — the API vertical
(apps/api/audiences/ + Drizzle tables), the EmailDestinationHandler publish fan-out, the SDK
clients, and the dashboard campaign/contacts UI — lands in later phases of the same feature branch.
Segmentation, automation, A/B testing, and open/click analytics are explicitly out of scope for v1.
