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 = falseto 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 liveestimatedCountof matching contacts. - Campaign — a named send with channels, an audience source (segment or inline filter), email content, and a status:
draft→scheduled/sending→sent/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
ContactActivityrow, 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)
- Click New Segment.
- Enter a Name (required) and optional Description.
- 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. - 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)
- Basics — enter a Campaign Name and check one or more Channels: Email, Facebook, LinkedIn, X (Twitter). Only
emailactually sends (see below); the others are recorded but not dispatched bysendCampaign. - 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."
- 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. - Click Save as Draft. Toast:
"Campaign saved as draft". Every new campaign starts indraftstatus — 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:
| Outcome | Final status |
|---|---|
| All recipients failed | failed |
| Some failed, some sent | partially_sent |
| None failed | sent |
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
| Status | Meaning |
|---|---|
draft | Being composed; recipients can be populated; can be sent or archived. |
scheduled | In the type/schema but no scheduling UI or scheduler currently sets this — reserved for future use. |
sending | Set the instant Send Campaign starts; in-progress state during the recipient loop. |
sent | Every attempted recipient succeeded. |
partially_sent | Some recipients failed, some succeeded. |
failed | Every 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:
| Factor | Weight | Basis |
|---|---|---|
| Email engagement | 25% | (opens×2 + clicks×5) / emails sent, capped at 100 |
| Event attendance | 25% | 25 points per met activity, capped at 100 |
| Response rate | 20% | replies received / campaign emails sent |
| Recency | 20% | 100 if last inbound activity ≤7 days ago, tapering to 10 if >60 days |
| Pipeline activity | 10% | 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
| Action | Minimum role |
|---|---|
| Create/update campaigns, populate recipients, create/list segments, log activity, view engagement scores, list/mark-read replies, create social posts | staff |
| Send a campaign, archive a campaign, delete a segment | lead |
| Log a share event | none (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.