Docs / Admin

Running campaigns and building audiences

Summary: The Communications hub (/communications) covers Campaigns, Audiences, Email Templates, and Replies. Campaigns start as drafts, target a saved segment or an inline filter, and require a lead-role staff member to explicitly populate recipients and click Send — there is no auto-send path in this module.

Purpose: Give staff a repeatable way to reach segments of contacts across email (and social channel fields, though only email actually sends), track who engaged, and see replies without leaving the platform.

Audience: Staff for building segments and drafting campaigns; lead and admin roles for sending and archiving.

Prerequisites:

  • At least one contact with an email address and is_archived = false to have anything to send to.
  • An email template (optional) — see Event email automation and templates for building reusable templates.

Overview

Key concepts:

  • Audience segment — a saved, named AudienceFilter (/communications/audiences) with a live estimatedCount of matching contacts.
  • Campaign — a named send with channels, an audience source (segment or inline filter), email content, and a status: draftscheduled / sendingsent / partially_sent / failed.
  • Recipient — one contact + channel + address on a specific campaign, with its own delivery status (pending, sent, delivered, opened, clicked, bounced, failed).
  • Activity ledger — every send, open, click, and reply is logged as a ContactActivity row, which feeds the engagement score.
  • Reply — an inbound message tied to a campaign/contact, shown unread-first on the Replies page.

Screenshot: The Campaigns list showing campaign name, channel/recipient summary, and a colored status badge (gray Draft, amber Sending, green Sent, orange Partially Sent, red Failed).


Building an audience

Saved segments (/communications/audiences)

  1. Click New Segment.
  2. Enter a Name (required) and optional Description.
  3. Under Filter, check any combination of Clusters (C1 — Smart Enabling Environment, C2 — Sustainable Investments, C3 — Energy) and Organization Types (Private Sector, Government / LGU, Development Partner, Chamber, Academic, NGO, International Org, Investor). Leaving both empty matches all contacts.
  4. Click Save Segment. The toast shows "Segment created — <N> contacts", where N is computed immediately by resolving the filter.

The segments list shows each segment's name, description, a filter summary (e.g. clusters: 2 · orgTypes: 1, or No filters), and a live <N> contacts badge. Deleting a segment (DeleteSegmentButton, lead role) is a soft delete — segments are archived, not hard-deleted.

What the filter actually supports today

AudienceFilter (src/lib/modules/communications/types.ts) declares many fields — membershipTiers, sectors, clusters, orgTypes, roles, tags, eventAttendance, pipelineInvolvement, excludeContactIds, excludeOrgIds — but the resolver (resolveAudience in services.ts) only implements clusters, orgTypes, excludeContactIds, and excludeOrgIds. The code comments this explicitly: other fields are accepted by the type but not yet applied, because organizations have no sector column (sectors live on investor_profiles) — they must be wired deliberately before being exposed in any segment-builder UI. The segment and campaign forms in the UI only expose Clusters and Org Types, consistent with what's implemented.

Every resolved contact must have a non-null email and not be archived (is_archived = false); the same is true of its organization when a cluster/org-type filter is used.


Creating and sending a campaign

Step 1 — Draft (/communications/campaigns/new)

  1. Basics — enter a Campaign Name and check one or more Channels: Email, Facebook, LinkedIn, X (Twitter). Only email actually sends (see below); the others are recorded but not dispatched by sendCampaign.
  2. Audience — pick a Saved Segment from the dropdown, or (if none selected) build an inline filter from the same Clusters/Org Types checkboxes as segments. The form notes: "Inline filter ignored when a saved segment is selected." and "Leave empty for all contacts with email addresses."
  3. Email Content (shown only when Email is checked) — optionally pick a Template (populates Subject and Body via renderTemplate), or compose Subject and Body (HTML or plain text) from scratch. Optionally set Reply-To Address / Reply-To Name, and check Track inbound replies.
  4. Click Save as Draft. Toast: "Campaign saved as draft". Every new campaign starts in draft status — there is no way to create a campaign in any other status.

Step 2 — Populate recipients

On the campaign detail page, a draft campaign shows a Populate Recipients button. Clicking it resolves the campaign's segment or inline filter into concrete recipients, snapshots recipient_count, and toasts "<N> recipients populated". This must be done before sending — recipients are not auto-populated at creation or on send.

Step 3 — Human review, then send

Send Campaign only appears while the campaign is draft, and only becomes enabled once canSend is true: content exists (subject + email body), an audience source is set, and at least one recipient has been populated. Clicking it shows a confirmation dialog: "Send to <N> recipients? This cannot be undone."

Sending a campaign (sendCampaignAction) requires the lead role — one step above the staff role that can draft campaigns and manage segments. This is the module's human-review gate: nothing auto-sends from a draft campaign; a lead must explicitly review and trigger the send. Consistent with the platform-wide rule that all external communications get human review before send in v1.

On send, the campaign is marked sending, each pending recipient is emailed via Resend (sendCampaignEmail), and the final status is computed:

OutcomeFinal status
All recipients failedfailed
Some failed, some sentpartially_sent
None failedsent

The toast reports "Sent <N>, failed <N>". sent_at is stamped once at least one email succeeds. The send action is logged to the audit trail (campaign.sent) with sent/failed counts.

Archiving

Archive (lead role, confirm dialog "Archive this campaign?") is available at any status. It is a soft delete (is_archived), not covered by any restore UI in the current codebase.


Campaign status reference

StatusMeaning
draftBeing composed; recipients can be populated; can be sent or archived.
scheduledIn the type/schema but no scheduling UI or scheduler currently sets this — reserved for future use.
sendingSet the instant Send Campaign starts; in-progress state during the recipient loop.
sentEvery attempted recipient succeeded.
partially_sentSome recipients failed, some succeeded.
failedEvery attempted recipient failed.

Tracking engagement: recipients, activity, and replies

Recipient stats (campaign detail page)

The Engagement card (shown once a non-draft campaign has recipients) reports, each with a percentage of total recipients: Recipients, Sent, Delivered, Opened, Clicked, Bounced (bounced + failed combined). Recipient statuses update via Resend delivery webhooks feeding CampaignRecipient.status.

Activity ledger

Every inbound response — via the public /api/comms/respond endpoint (token-verified) — is recorded both as a CampaignResponse row and as a ContactActivity entry (direction: "inbound", channel: "email", activityType: "replied", sourceModule: "communications"). Staff can also log activity manually (logActivityAction, requires either a contactId or orgId) across channels: email, whatsapp, phone, meeting, social, sms, note, system.

Replies page (/communications/replies)

Lists rows from campaign_replies with an Unread / All toggle (?unread=1). Unread replies show a blue-tinted background and a filled mail icon; opening a reply row (or clicking it) marks it read via markReplyReadAction. Each reply shows the sender address, subject, a body preview (expandable), the received timestamp, and — if tied to a campaign — a "View campaign →" link. The page footer shows Unread count: <N> when viewing all replies.

Gap found: no code path in this repo currently inserts rows into campaign_replies (no inbound-email webhook route calls the repository's reply insert). The Replies UI and read/unread tracking are fully built, but nothing populates it yet — inbound reply ingestion (e.g. a Resend inbound-parse webhook) is not wired up.

Engagement score

A contact's EngagementScore (0–100, src/lib/modules/communications/activity/scoring.ts) is a weighted composite of five factors computed from the last 90 days of activity:

FactorWeightBasis
Email engagement25%(opens×2 + clicks×5) / emails sent, capped at 100
Event attendance25%25 points per met activity, capped at 100
Response rate20%replies received / campaign emails sent
Recency20%100 if last inbound activity ≤7 days ago, tapering to 10 if >60 days
Pipeline activity10%20 points per pipeline-sourced activity, capped at 100

Trend is rising if the last 30 days' activity count exceeds 1.3× the prior 30 days, declining if under 0.7×, else stable. Recalculation is intended to run via a daily Inngest cron or on demand; only the calculation function was found — confirm the cron wiring separately if you rely on it running automatically.


Roles and permissions

ActionMinimum role
Create/update campaigns, populate recipients, create/list segments, log activity, view engagement scores, list/mark-read replies, create social postsstaff
Send a campaign, archive a campaign, delete a segmentlead
Log a share eventnone (public — used by public-facing share buttons)

All actions are gated with requireRole(...) in src/lib/modules/communications/actions.ts.


Best practices

Populate recipients right before sending, not right after drafting. Segment membership can shift as contacts are added or archived; populating late keeps the recipient list current.

Use saved segments for anything you'll send more than once. Inline filters are fine for one-off campaigns, but a saved segment gives you the live contact-count badge and is reusable across future campaigns.

Check the recipient count in the confirm dialog before sending. The confirmation text names the exact recipient count — a mismatch from what you expect is a signal to re-check the audience filter before proceeding.


Warnings

Sending cannot be undone. The confirm dialog says so explicitly. There is no un-send, cancel-mid-send, or retry-only-failed action in the current UI.

Social channels are recorded but not dispatched. Selecting Facebook, LinkedIn, or X as a channel on a campaign records the choice but sendCampaign only sends email; there is no code path in this module that posts to those platforms from a campaign (social posting is a separate SocialPost entity with its own create/list actions, not tied to campaign send).


Troubleshooting

"Campaign has no email content or subject"

Cause: Attempting to send a campaign with email as a channel but no subject and no emailContent set. Fix: Open the campaign, add subject and body content (via Edit — note the current UI only exposes editing at creation; adjust the draft in the new-campaign form flow or via a direct campaign update action), then send.

"Campaign has no audience filter or segment"

Cause: Populate Recipients was clicked on a campaign with neither a saved segment nor an inline audience filter set. Fix: Set an audience (segment or filter) on the campaign before populating recipients.

"Audience segment not found"

Cause: The campaign references a segment that was deleted (archived) after the campaign was created. Fix: Select a different segment, or switch the campaign to an inline filter.

Send Campaign button is disabled

Cause: canSend is false — one of: campaign isn't draft, no subject+body content, no audience source, or zero recipients populated. Fix: Confirm all four conditions: draft status, content filled in, an audience set, and recipients populated via Populate Recipients.


FAQ

Can a staff member (not lead) send a campaign? No. sendCampaignAction requires the lead role. Staff can draft, populate recipients, and prepare everything else, but only a lead (or admin) can trigger the actual send.

Does the segment's contact count update automatically as contacts change? No — estimatedCount is computed once at segment creation. Re-saving or recreating the segment is the only way to refresh it in the current code.

Can I schedule a campaign for a future date? The scheduledFor field and scheduled status exist in the schema, but no UI control sets a future send time and no scheduler transitions a campaign into scheduled automatically.

Where do inbound replies come from? Currently: nowhere automatically. The Replies page and reply-tracking schema exist, but no webhook or ingestion route in this codebase writes to campaign_replies yet — see the Gap noted above.


Related articles

  • Event email automation and templates — building the templates campaigns pull from, and the separate event-driven email sequences
  • A dedicated guide to the social-posting (SocialPost) workflow is planned.