Skip to content

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.

Contact who the organisation emails (consent-tracked)
Audience a named list
Audience ──< 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. userId is a creator/audit stamp only — reads are scoped by orgId, 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 an AudienceService concern, 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 codebase member already 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 recordWhat it modelsWhy 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.

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.

Pure domain, infrastructure-free — same shape as every other vertical:

LayerLocation
Entitiespackages/platform-domain/src/audiences/entities/{Contact,Audience}.ts
Portspackages/platform-domain/src/audiences/ports/{IContactRepository,IAudienceRepository}.ts
Servicespackages/platform-domain/src/audiences/services/{ContactService,AudienceService}.ts
In-memory adapters (tests)packages/platform-domain/src/audiences/adapters/InMemory*.ts
Email (campaign) lane constantspackages/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.

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.