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 |
| Built-in layouts + renderer | packages/platform-domain/src/tasks/destinations/emailLayouts.ts |
| Send fan-out | apps/api/publishing/handlers/EmailDestinationHandler.ts (via the publish step) |
| Dashboard composer | apps/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.
Composing a campaign: layouts
Section titled “Composing a campaign: layouts”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_LAYOUTS—simple,announcement,newsletter). The author picks one and fills its holes;payload.layoutId+payload.layoutValuesare what persist. - Free-form HTML — no
layoutId, andpayload.bodyHtmlis 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.
Enabling it
Section titled “Enabling it”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.
Not yet built
Section titled “Not yet built”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.
