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
Built-in layouts + rendererpackages/platform-domain/src/tasks/destinations/emailLayouts.ts
Send fan-outapps/api/publishing/handlers/EmailDestinationHandler.ts (via the publish step)
Dashboard composerapps/dashboard/src/components/EmailTaskEditor.tsx, routes/campaigns.new.tsx

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.

A campaign body comes from one of two modes, resolved by resolveCampaignBody(payload) in packages/platform-domain/src/tasks/destinations/emailLayouts.ts:

  • A built-in layout (EMAIL_LAYOUTSsimple, announcement, newsletter). The author picks one and fills its holes; payload.layoutId + payload.layoutValues are what persist.
  • Free-form HTML — no layoutId, and payload.bodyHtml is sent verbatim. The original mode, still there for an email pasted in from elsewhere.

Layouts are product constants, not templates rows: they must exist on a fresh deployment with nothing authored, they don’t depend on the templates feature flag, and they have no per-org variation. Making them data would buy nothing and cost a migration plus a “no layouts yet” failure mode. (The generic Template/TemplateInstance vertical is a separate, user-authored thing.)

Each field declares type: 'html' (the rich-text body — inserted verbatim, the same trust level bodyHtml always had) or type: 'text' (HTML-escaped). That distinction is why layouts don’t reuse renderTemplate, which escapes everything and would render the body as visible tags. {{#key}}…{{/key}} sections drop a block whose value is empty — used for the optional call-to-action, and nothing else.

Rendering runs in two stages, and the order matters: the layout is poured once per campaign, then the {{name}} / {{email}} personalization pass runs once per recipient over the result — so a {{name}} typed into a layout field still resolves per recipient.

The dashboard composer previews through the same resolveCampaignBody, so the preview cannot drift from the inbox. It also returns missingKeys, which is what both the composer and the send path use to decide a campaign is empty: a layout with every hole blank still renders its full shell, so a non-empty body is never evidence of a non-empty email.

The whole recipient side sits behind the audiences feature flag (AUDIENCES_ENABLED=true). Off, /api/contacts and /api/audiences return 404 and the dashboard hides /audiences and bounces /campaigns — leaving campaigns with nobody to send to. On a Coolify deployment the var must also be declared in docker-compose.coolify.yml; a value set only in Coolify never reaches the container.

Sending additionally needs RESEND_API_KEY and SMTP_FROM. With no key, sends degrade to a logged no-op. With an empty SMTP_FROM the adapter falls back to Resend’s sandbox sender, which reports success and drops mail to anyone but the account owner — set it to an address on a Resend-verified domain.

Segmentation, automation, A/B testing, open/click analytics, and unsubscribe/bounce handling are out of scope for v1 — Contact.status is the seam the last two hang off.