Documentation menu

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_requests row with action communications.send, resource type campaign. 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.
  • scheduledscheduled_at is in the future; communications.scheduler enqueues 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: markedsanitize-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 transactional campaign 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 skipped and reason opted_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.