Communications
Campaigns from an application to the people it knows, over email, in-app and Slack, with a second operator's approval in front of every send and a delivery row behind every message.
Owner: W12b. Module key communications. Permissions communications.read,
communications.write (author and operate), communications.approve (decide).
The flow
draft ──submit──▶ pending_approval ──approve──▶ approved ──send now──┐
▲ │ │ │
└────── reject ───────┘ schedule ──▶ scheduled ─┤
▼
paused ◀──pause── sending ──▶ completed
│
└──▶ failed
- draft — the only editable state. Name, kind, channels, audience and content can all change; nothing has been promised to anybody yet.
- pending_approval — submitting writes an
approval_requestsrow with actioncommunications.send, resource typecampaign. The campaign page and the organization's operations page both decide the same row. - approved — a second operator agreed. The audience is snapshotted so the approval records how many people it authorised.
- scheduled —
scheduled_atis in the future;communications.schedulerenqueues the send when it arrives. - sending — deliveries exist and the per-channel jobs are working through them. Pause stops at the next batch boundary; cancel skips everything still queued.
- completed — nothing is queued on any channel.
- failed — the audience no longer resolves (a deleted segment, an empty match). The reason is on the campaign.
The four-eyes rule
A campaign must be approved by someone other than its author. It is enforced in
decideCampaign and re-checked in executeApprovedCampaign, so it holds
whether the decision was taken on the campaign page, on the operations page, or
by the recurring job that promotes approvals the request never got to execute.
The console hides the approve button from the author, but hiding a button is not
a control.
Approvals expire after seven days. An expired or rejected approval sends the
campaign back to draft.
Channels
| Channel | Reaches | How |
|---|---|---|
email |
A person with an address (persons.email), or a tenant user through the connector |
sendEmail (W8) for people we know; POST /communications/send with channel: "email" for connector users, so we never ask the tenant for an address we would then have to store |
in_app |
A tenant user id | POST /communications/send with channel: "in_app", batched 200 users per signed request, acting as the campaign's author |
slack |
A room, not a person | Every enabled notification_channels row of kind slack, through W8's Block Kit payload and the webhook URL in the vault |
A campaign can pick any combination. Each channel gets its own delivery rows and its own delivery job, so a Slack outage does not hold up the email.
In-app from a people audience. The connector addresses its own users by id.
For a segment or a static list, the person's distinct_id is used as the tenant
user id — that is the identifier the SDK's identify call carries, so it is the
tenant's own id in every normal integration. A person without one is skipped
with reason no_tenant_user.
Audiences
{ kind: "segment", segmentId } // W6 segment, dynamic or static
{ kind: "static", personIds: [...] } // explicit person ids of this application
{ kind: "connector", connectorQuery: { q } } // the tenant's own user directory, paged
A dynamic segment is a query, not a list, so the audience is resolved twice:
once when the campaign is approved (advisory — it puts a number on the approval)
and again the moment the send starts. What the recipients actually were is then
frozen in campaign_deliveries, which is what the console reads afterwards.
The cap is 25 000 recipients. A larger audience is refused rather than truncated, because a truncated send is worse than no send.
The snapshot's sample labels are masked (re••••@example.com) before they are
written: audience_snapshot is readable with communications.read, which is a
weaker gate than people.pii.read.
Content
Markdown, rendered on the server: marked → sanitize-html, in that order, so
an HTML block an author pastes still has to survive the allow-list. Scripts,
iframes, event handlers and javascript: URLs never reach a recipient. The
preview an author sees comes from the same function the send pipeline uses, over
the preview intent — it is not a client-side guess.
{ subject?, title?, body /* markdown */, cta?: { label, url } }
subject is the email subject and is required for an email campaign. title is
the in-app heading and the Slack header. The call to action becomes a link at
the end of the body and is tracked like any other.
Tracking and unsubscribe
Tracked links. When a send starts, every http(s) link in the rendered body
is stored in campaign_links with an index. Each email delivery gets its own
copy of the HTML with href values rewritten to /c/<deliveryId>/<index>.
GET /c/<deliveryId>/<index>
→ 302 to the URL stored at that index
→ sets campaign_deliveries.clicked_at (first click only)
→ increments campaign_links.clicks
→ records $campaign_click with { campaignId, index, url } through W4's
recordInternalEvent, so the click lands on the person's timeline
→ 404 for an unknown delivery, an unknown index, or a non-integer index
The destination is always read from the table by index. There is no ?url=
parameter and no fallback, which is what makes the route impossible to turn into
an open redirect. The analytics write is best effort: a recipient never sees an
error page because ingest was busy.
Unsubscribe. Every non-transactional email carries
/u/<token> in its footer. The token is derived —
hmac("communications:unsubscribe:<appId>:<personId>", SPM_SESSION_SECRET) — so
a link sent months ago still works while the database stores only its sha256.
GET /u/<token> confirmation page, marketing register, no session
POST /u/<token>/confirm action=unsubscribe | resubscribe
There is no CSRF token because there is no session to protect: the unsubscribe token is the credential, and the worst a forged post can do is unsubscribe someone from mail they can resubscribe to on the same page. Enumeration is guarded by a per-client token bucket (10 confirmations, one back per minute), keyed on the hashed IP — the public routes never see or store a raw address.
Opt-out semantics.
- The opt-out applies to the email channel only. In-app notices are part of using the product and Slack does not address a person at all.
- A
transactionalcampaign carries no unsubscribe footer and is not gated on the flag: receipts and password resets are not marketing. - An opted-out recipient still gets a delivery row, with status
skippedand reasonopted_out, so the operator can see the audience was larger than the send.
Delivery, retries and statistics
Two jobs, deliberately split. communications.send materialises the audience
exactly once; communications.deliver does the talking to providers, so a
provider outage retries the batch rather than re-resolving a segment that has
moved on.
A batch is leased, not merely selected: claiming pushes next_attempt_at
five minutes into the future inside the same statement, under FOR UPDATE SKIP LOCKED. Two delivery jobs for one channel — the batch that was already running
and a retry an operator triggered — therefore cannot send the same message
twice.
A failed delivery keeps its attempt count and comes back after 30 s, 60 s then
120 s. After three attempts it is failed and stays there until an operator
retries it from the delivery log.
Statistics are always recomputed from the delivery rows and cached on
campaigns.stats; they are never incremented in place, so a retried delivery
cannot leave the numbers wrong. communications.stats refreshes every in-flight
campaign every five minutes and completes any campaign with nothing left queued.
Every person who receives a campaign gets a person_activities row with kind
campaign, so the people dossier shows what was sent to them.
Jobs
| Kind | Trigger | Does |
|---|---|---|
communications.send |
send now, scheduler | Resolves the audience, writes deliveries and links, enqueues one deliver job per channel |
communications.deliver |
send, retry, itself | One batch on one channel, then schedules the next round at the earliest next_attempt_at |
communications.scheduler |
every 60 s | Enqueues sends for campaigns whose scheduled_at has passed |
communications.approvals |
every 60 s | Promotes approved requests and mirrors rejected or expired ones back to draft |
communications.stats |
every 5 min | Recomputes statistics for sending campaigns and completes the drained ones |
Limits
| Thing | Limit |
|---|---|
| Recipients per campaign | 25 000 |
| Connector user pages | 50 pages of 200 |
| Deliveries per batch | 50 email, 200 in-app, 20 Slack |
| Attempts per delivery | 3 |
| Approval lifetime | 7 days |
| Body | 40 000 characters of markdown |
| Person ids in a static audience | 5 000 |
| Deliveries listed per page | 500 |
Tables
campaigns (id, org_id, app_id, env_id, name, kind, status, channels jsonb, audience jsonb,
audience_snapshot jsonb, content jsonb, stats jsonb, scheduled_at, started_at, completed_at,
last_error, created_by, approved_by, approval_request_id, created_at, updated_at)
campaign_deliveries (id, org_id, app_id, campaign_id, person_id, tenant_user_id, channel_id,
channel, destination_hash, status, provider_ref, attempts, next_attempt_at, last_error,
sent_at, opened_at, clicked_at, created_at, updated_at)
UNIQUE (campaign_id, channel, coalesce(person_id,''), coalesce(tenant_user_id,''), coalesce(channel_id,''))
campaign_links (campaign_id, "index", url, clicks, created_at)
communication_preferences (app_id, person_id, org_id, email_opt_out, token_hash, opted_out_at,
updated_at, created_at) UNIQUE (token_hash)
destination_hash is sha256(lower(email)). It proves two deliveries went to
the same person without the table becoming a mailing list; the address itself is
read from persons for exactly one send and never written here.
For other modules
import { listCampaigns, campaignStats } from "@/lib/communications/queries";
listCampaigns({ orgId, appId }, { status?, limit? }): Promise<CampaignRecord[]>
campaignStats(campaignId): Promise<CampaignStats> // recomputes and caches
Nothing in src/lib/communications/** imports server-only: the worker imports
this module, and that import throws outside Next.
What the assistant can do here
Ask can read campaigns for anyone holding communications.read: the list with
its status counts, one campaign with sent, delivered, opened and clicked, and the
delivery statistics behind them. That is enough for "how did the launch email
do?" without opening the module.
It cannot send. Authoring, submitting, approving and scheduling a campaign are console actions, and the second-operator approval this module requires is a decision about a human's message to real people — not something to delegate to a model. The assistant will say that the capability is missing rather than propose a send.