From d67cdb8562603d6370dcca6cd88033399eecbdde Mon Sep 17 00:00:00 2001
From: Matthew Meszaros
Date: Sat, 29 Aug 2026 03:12:02 -0700
Subject: [PATCH] feat: add recipient-level engagement to the campaign Leads
view by adding an engagement search filter (opened, not_opened, clicked,
not_clicked, replied, not_replied, bounced) that composes with lead_status as
AND and rejects unknown values with stable 400 codes, counting only human
opens so machine opens never read as engagement, adding
contacted/opened/clicked/replied_any totals to lead_counts, rendering
Opened/Clicked/Replied columns and clickable server-backed status and
engagement chips in the Leads table with matching Filters sheet sections,
offering lead_status/lead_opened/lead_clicked/lead_replied export columns
from a campaign, covering every filter value with a live Postgres test, and
documenting the columns, filters, error codes and export fields (issue #250)
---
docs/content/docs/api/error-codes.mdx | 3 +
docs/content/docs/api/reference/contacts.mdx | 11 +-
docs/content/docs/guides/campaigns.mdx | 8 +
internal/app/contact/export.go | 48 +++-
internal/app/contact/handler.go | 28 ++-
internal/models/contact.go | 38 +++-
internal/models/contact_export.go | 6 +
.../contact_engagement_live_test.go | 142 ++++++++++++
internal/repository/pg_contact.go | 61 +++++-
.../app/contacts/ContactFilters.tsx | 87 ++++++++
.../components/app/contacts/ContactsTable.tsx | 205 ++++++++++++++++--
.../components/app/contacts/ExportDialog.tsx | 18 +-
.../lib/api/models/app/contacts/Contact.ts | 15 ++
.../api/models/app/contacts/SearchContacts.ts | 5 +
.../app/contacts/SearchContactsResult.ts | 6 +
15 files changed, 636 insertions(+), 45 deletions(-)
create mode 100644 internal/repository/contact_engagement_live_test.go
diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx
index 89d287d3..b2d13a15 100644
--- a/docs/content/docs/api/error-codes.mdx
+++ b/docs/content/docs/api/error-codes.mdx
@@ -75,6 +75,9 @@ Returned when the request cannot be processed due to invalid syntax.
| `code` | Meaning |
|--------|---------|
+| `invalid_lead_status` | `POST /contacts/search` or `POST /contacts/export` was given a `lead_status` that is not one of the documented values |
+| `invalid_engagement` | `POST /contacts/search` or `POST /contacts/export` was given an `engagement` that is not one of the documented values |
+| `lead_filter_requires_campaign` | `lead_status` or `engagement` was set without exactly one `campaign_ids` entry; both filters describe a contact inside one campaign |
| `no_organization` | The request needs a workspace and the caller has none selected. Every entitlement, limit and suppression rule is scoped to a workspace, so a write that would run unscoped is refused rather than run without those checks. API keys always carry their workspace; a dashboard session picks one at sign-in, so this normally means the session predates the workspace being chosen. Select a workspace and retry |
### 401 Unauthorized
diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx
index 49936944..7e38acf2 100644
--- a/docs/content/docs/api/reference/contacts.mdx
+++ b/docs/content/docs/api/reference/contacts.mdx
@@ -28,7 +28,8 @@ Every field is optional; an empty body matches all contacts in the organization.
| `query` | string | No | Text search across core fields (name, email, company). |
| `custom_field_filters` | array | No | Per custom-field filters: `{ "name", "value", "type" }` where `type` is one of `equal`, `starts_with`, `ends_with`, `contains`. |
| `campaign_ids` | string[] | No | Contact must be in ALL of these campaigns. |
-| `lead_status` | string | No | Filter to one derived lead status: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `undeliverable`, or `unsubscribed`. Requires exactly one `campaign_ids` entry, otherwise the request is rejected. |
+| `lead_status` | string | No | Filter to one derived lead status: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `undeliverable`, or `unsubscribed`. Requires exactly one `campaign_ids` entry, otherwise the request is rejected with `lead_filter_requires_campaign`; an unknown value is rejected with `invalid_lead_status`. |
+| `engagement` | string | No | Filter by engagement inside that campaign: `opened`, `not_opened`, `clicked`, `not_clicked`, `replied`, `not_replied`, or `bounced`. `opened` means a human open (machine opens never count); the `not_*` values match only leads sent at least one step. Combines with `lead_status` as AND. Requires exactly one `campaign_ids` entry (`lead_filter_requires_campaign`); an unknown value is rejected with `invalid_engagement`. |
| `category_ids` | string[] | No | Contact must have ALL of these categories. |
| `min_campaigns` | integer | No | Minimum number of associated campaigns. |
| `max_campaigns` | integer | No | Maximum number of associated campaigns. |
@@ -85,9 +86,9 @@ Returns a `data` array of contacts plus a `pagination` envelope.
}
```
-When the search filters by exactly one campaign, each contact additionally carries a `campaign_lead` object with its processing state inside that campaign (`status`, `sent`, `opened`, `clicked`, `replied`, `bounced`, `current_step`, `last_activity_at`, and `failure_reason` when failed). The `status` derivation, highest priority first, is `unsubscribed` (not subscribed), then `bounced`, `replied`, `failed` (a step could not be sent after every retry; `failure_reason` carries the sending worker's reason), `completed` (every email step sent, no reply), `active` (some steps sent, more to send), `undeliverable` (pre-send verification refused the address, so the campaign skips the lead and never sends to it), and `pending` (queued, nothing sent). A step counts as sent only once the sending worker has delivered it to the mailbox provider; a send the worker could not complete is retried on the campaign's next pass and never shows as sent. The `lead_status` filter narrows to one of these buckets.
+When the search filters by exactly one campaign, each contact additionally carries a `campaign_lead` object with its processing state inside that campaign (`status`, `sent`, `opened`, `machine_opened`, `clicked`, `replied`, `bounced`, `current_step`, `last_activity_at`, and `failure_reason` when failed). `opened` counts steps opened by a person; steps fetched automatically by a mail client (Apple Mail Privacy Protection and similar) are in `machine_opened` instead, matching the machine opens the analytics summary reports. The `status` derivation, highest priority first, is `unsubscribed` (not subscribed), then `bounced`, `replied`, `failed` (a step could not be sent after every retry; `failure_reason` carries the sending worker's reason), `completed` (every email step sent, no reply), `active` (some steps sent, more to send), `undeliverable` (pre-send verification refused the address, so the campaign skips the lead and never sends to it), and `pending` (queued, nothing sent). A step counts as sent only once the sending worker has delivered it to the mailbox provider; a send the worker could not complete is retried on the campaign's next pass and never shows as sent. The `lead_status` filter narrows to one of these buckets.
-When the search filters by exactly one campaign, the first page (no `cursor`) also includes a `lead_counts` object: per-status lead totals for that campaign, independent of the `lead_status` filter so every scope's total is available at once.
+When the search filters by exactly one campaign, the first page (no `cursor`) also includes a `lead_counts` object: per-status lead totals for that campaign, independent of the `lead_status` and `engagement` filters so every scope's total is available at once. Alongside the status buckets it carries engagement totals that match the `engagement` filter: `contacted` (leads sent at least one step), `opened` (a human open on any step), `clicked`, and `replied_any` (a reply on any step, whatever the derived status).
```json
{
@@ -267,9 +268,11 @@ Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`
| `scope` | string | Yes | `all`, `filtered`, or `selected`. |
| `contact_ids` | string[] | No | Contact IDs when `scope` is `selected`. |
| `filters` | object | No | A search-contacts filter body when `scope` is `filtered`. |
-| `fields` | string[] | No | Column identifiers in display order (built-ins like `email`, `first_name`, or `custom:`). Empty uses the default columns. |
+| `fields` | string[] | No | Column identifiers in display order (built-ins like `email`, `first_name`, or `custom:`). Empty uses the default columns. `lead_status`, `lead_opened`, `lead_clicked` and `lead_replied` are the contact's engagement inside the one campaign named in `filters.campaign_ids`; they are blank when the filters do not name exactly one campaign. |
| `filename` | string | No | Filename without extension. Sanitized server-side; empty falls back to `contacts-`. |
+With `scope` set to `selected`, `filters` is optional and is applied on top of `contact_ids`; pass the campaign there to populate the `lead_*` columns for the selected rows.
+
```json
{
"format": "csv",
diff --git a/docs/content/docs/guides/campaigns.mdx b/docs/content/docs/guides/campaigns.mdx
index 6a1121ce..ff6466a8 100644
--- a/docs/content/docs/guides/campaigns.mdx
+++ b/docs/content/docs/guides/campaigns.mdx
@@ -75,6 +75,14 @@ The **Leads** tab takes contacts four ways. **From contacts** opens a picker ove
A lead is **Processing** only while steps remain, so a finished campaign reads as done rather than stuck mid-flight.
+### Who opened, clicked and replied
+
+Next to each lead's status, the Leads list shows three engagement columns: **Opened**, **Clicked** and **Replied**, each with the number of emails in the sequence the person engaged with. A dash means the lead was emailed and has not engaged; the cell is blank for a lead not emailed yet. An open counts only when a person opened the email. Mail clients that fetch every image automatically (Apple Mail Privacy Protection, for example) show as **auto** instead, the same opens the campaign overview reports as automatic, so they never pass for engagement.
+
+The chips above the list are filters. Click a status chip (**Processing**, **Done**, **Replied**, **Queued**, **Bounced**, **Unsub**) or an engagement chip (**Opened**, **Not opened**, **Clicked**, **Not clicked**, **Replied**, **Not replied**) to show only those leads; click it again to clear. One status and one engagement chip can be active at once and both must match. The numbers on the chips are campaign-wide totals, and filtering happens on the server, so a scope shows every matching lead however long the list is. **Not opened**, **Not clicked** and **Not replied** only cover leads that have been sent at least one email: a queued lead has not had the chance. The same filters live in the **Filters** sheet under **Lead status** and **Engagement**.
+
+Engagement updates live as opens, clicks and replies arrive; no refresh is needed. **Export** from the Leads tab offers four extra columns (**Lead status**, **Opened**, **Clicked**, **Replied**), whether you export the current scope or selected rows.
+
A step counts as sent only once the sending worker has handed it to the mailbox provider. If the worker cannot send (the mailbox was still loading, the provider refused the message, a storage hiccup), the step goes back to the queue, the failure appears in **Needs attention** with the reason, and the next pass retries it. After five failed attempts the lead is marked **Failed** and dropped from the campaign, so a mailbox that can never send does not loop forever. A recipient the mail server refuses outright is not retried: it is recorded as a **Bounced** lead straight away and goes through the same bounce handling (suppression, guardrails, webhooks) as a bounce notice. A campaign that finished while a send was still in flight reopens to retry it.
No contact is emailed the same step twice. Each step is recorded as attempted before the send is handed to a worker, so a crash, a restart, or a database hiccup in the moment between the two cannot make the step look unsent and send it again. The trade-off is a step whose outcome is genuinely unknown, when the worker stops responding mid-send: after 30 minutes with no answer the step is treated as a failed attempt, appears in **Needs attention**, and is retried like any other failure.
diff --git a/internal/app/contact/export.go b/internal/app/contact/export.go
index 6444283d..a5ae0089 100644
--- a/internal/app/contact/export.go
+++ b/internal/app/contact/export.go
@@ -65,6 +65,14 @@ func (s *contactService) Export(
return "", "", 0, errx.New(errx.BadRequest, "contact_ids required when scope=selected")
}
contactIDs = req.ContactIDs
+ // Filters are optional here: a campaign Leads export passes its
+ // campaign so the lead_* columns are populated for the selected rows.
+ searchFilters = req.Filters
+ }
+ if searchFilters != nil {
+ if err := validateLeadFilters(*searchFilters); err != nil {
+ return "", "", 0, err
+ }
}
rows, xerr := s.contactRepository.ExportAll(ctx, userID, searchFilters, contactIDs, models.MaxContactExportRows)
@@ -145,7 +153,11 @@ func validateFieldList(fields []string) *errx.Error {
models.ContactExportFieldCategories,
models.ContactExportFieldCampaigns,
models.ContactExportFieldCreatedAt,
- models.ContactExportFieldUpdatedAt:
+ models.ContactExportFieldUpdatedAt,
+ models.ContactExportFieldLeadStatus,
+ models.ContactExportFieldLeadOpened,
+ models.ContactExportFieldLeadClicked,
+ models.ContactExportFieldLeadReplied:
default:
return errx.New(errx.BadRequest, "unknown export field: "+f)
}
@@ -183,6 +195,14 @@ func fieldHeader(f string) string {
return "Created At"
case models.ContactExportFieldUpdatedAt:
return "Updated At"
+ case models.ContactExportFieldLeadStatus:
+ return "Lead Status"
+ case models.ContactExportFieldLeadOpened:
+ return "Opened"
+ case models.ContactExportFieldLeadClicked:
+ return "Clicked"
+ case models.ContactExportFieldLeadReplied:
+ return "Replied"
}
return f
}
@@ -235,10 +255,34 @@ func fieldValue(c *models.Contact, f string) string {
return ""
}
return c.UpdatedAt.UTC().Format(time.RFC3339)
+ case models.ContactExportFieldLeadStatus:
+ if c.CampaignLead == nil {
+ return ""
+ }
+ return c.CampaignLead.Status
+ case models.ContactExportFieldLeadOpened, models.ContactExportFieldLeadClicked, models.ContactExportFieldLeadReplied:
+ if c.CampaignLead == nil {
+ return ""
+ }
+ return strconv.FormatBool(leadEngaged(c.CampaignLead, f))
}
return ""
}
+// leadEngaged is the yes/no engagement cell: any human open, click or reply on
+// any step of the campaign.
+func leadEngaged(lead *models.ContactCampaignProgress, f string) bool {
+ switch f {
+ case models.ContactExportFieldLeadOpened:
+ return lead.Opened > 0
+ case models.ContactExportFieldLeadClicked:
+ return lead.Clicked > 0
+ case models.ContactExportFieldLeadReplied:
+ return lead.Replied > 0
+ }
+ return false
+}
+
// writeCSV uses encoding/csv from the stdlib. The leading UTF-8 BOM is
// what Excel-on-Windows looks for to render non-ASCII characters
// correctly when opening a CSV directly — without it, names like "Söre"
@@ -285,6 +329,8 @@ func writeJSON(w io.Writer, rows []models.Contact, fields []string) error {
obj[f] = c.Categories
} else if f == models.ContactExportFieldCampaigns {
obj[f] = c.Campaigns
+ } else if c.CampaignLead != nil && (f == models.ContactExportFieldLeadOpened || f == models.ContactExportFieldLeadClicked || f == models.ContactExportFieldLeadReplied) {
+ obj[f] = leadEngaged(c.CampaignLead, f)
} else {
obj[f] = fieldValue(c, f)
}
diff --git a/internal/app/contact/handler.go b/internal/app/contact/handler.go
index 185bf019..cf0081da 100644
--- a/internal/app/contact/handler.go
+++ b/internal/app/contact/handler.go
@@ -85,21 +85,29 @@ func (s *contactService) Search(ctx context.Context, orgID, cursor, category, li
return nil, err
}
- // The lead_status filter is a single-campaign Leads-view feature: an invalid
- // value or the wrong campaign cardinality is a client contract error (400),
- // not a silently-ignored no-op.
- if filters.LeadStatus != "" {
- if !models.ValidLeadStatus(filters.LeadStatus) {
- return nil, errx.New(errx.BadRequest, "invalid lead_status")
- }
- if len(filters.CampaignIDs) != 1 {
- return nil, errx.New(errx.BadRequest, "lead_status requires exactly one campaign_id")
- }
+ if err := validateLeadFilters(filters); err != nil {
+ return nil, err
}
return s.contactRepository.Search(ctx, orgID, categoryId, cursorId, filters, limitN)
}
+// validateLeadFilters gates the single-campaign Leads-view filters: an unknown
+// value or the wrong campaign cardinality is a client contract error (400 with
+// a stable code), not a silently-ignored no-op.
+func validateLeadFilters(filters models.SearchContacts) *errx.Error {
+ if filters.LeadStatus != "" && !models.ValidLeadStatus(filters.LeadStatus) {
+ return errx.NewWithIdentifier(errx.BadRequest, "invalid_lead_status", "invalid lead_status")
+ }
+ if filters.Engagement != "" && !models.ValidLeadEngagement(filters.Engagement) {
+ return errx.NewWithIdentifier(errx.BadRequest, "invalid_engagement", "invalid engagement")
+ }
+ if (filters.LeadStatus != "" || filters.Engagement != "") && len(filters.CampaignIDs) != 1 {
+ return errx.NewWithIdentifier(errx.BadRequest, "lead_filter_requires_campaign", "lead_status and engagement require exactly one campaign_id")
+ }
+ return nil
+}
+
func (s *contactService) SearchCounts(ctx context.Context, orgID string) (*models.ContactsCounts, *errx.Error) {
return s.contactRepository.SearchCounts(ctx, orgID)
}
diff --git a/internal/models/contact.go b/internal/models/contact.go
index 59c3baa4..0aafe254 100644
--- a/internal/models/contact.go
+++ b/internal/models/contact.go
@@ -67,9 +67,11 @@ type ContactCampaignProgress struct {
// bounced — a send hard-bounced (terminal/negative)
// failed — the mailbox could not send a step after every retry (terminal/negative)
// unsubscribed — the contact is unsubscribed/suppressed (terminal)
- Status string `json:"status"`
- Sent int `json:"sent"`
+ Status string `json:"status"`
+ Sent int `json:"sent"`
+ // Opened counts human opens only; automated fetches are in MachineOpened.
Opened int `json:"opened"`
+ MachineOpened int `json:"machine_opened"`
Clicked int `json:"clicked"`
Replied int `json:"replied"`
Bounced int `json:"bounced"`
@@ -110,6 +112,30 @@ func ValidLeadStatus(s string) bool {
}
}
+// Lead engagement filter values for SearchContacts.Engagement. Each is a
+// predicate over the contact's progress rows in one campaign; the negative
+// forms only match leads that were sent at least one step, so a lead never
+// emailed is neither "opened" nor "not opened".
+const (
+ LeadEngagementOpened = "opened"
+ LeadEngagementNotOpened = "not_opened"
+ LeadEngagementClicked = "clicked"
+ LeadEngagementNotClicked = "not_clicked"
+ LeadEngagementReplied = "replied"
+ LeadEngagementNotReplied = "not_replied"
+ LeadEngagementBounced = "bounced"
+)
+
+// ValidLeadEngagement reports whether s is one of the engagement filter values.
+func ValidLeadEngagement(s string) bool {
+ switch s {
+ case LeadEngagementOpened, LeadEngagementNotOpened, LeadEngagementClicked, LeadEngagementNotClicked, LeadEngagementReplied, LeadEngagementNotReplied, LeadEngagementBounced:
+ return true
+ default:
+ return false
+ }
+}
+
type ContactsResult struct {
Data []Contact `json:"data"`
Pagination Pagination `json:"pagination"`
@@ -141,6 +167,13 @@ type CampaignLeadCounts struct {
Unsubscribed int `json:"unsubscribed"`
// Undeliverable: address verification refused it, so routing skips it.
Undeliverable int `json:"undeliverable"`
+ // Engagement totals, matching the `engagement` search filter: leads sent
+ // at least one step, and of those the ones with a human open, a click, or
+ // a reply on any step.
+ Contacted int `json:"contacted"`
+ Opened int `json:"opened"`
+ Clicked int `json:"clicked"`
+ RepliedAny int `json:"replied_any"`
}
// ContactsCounts are org-wide contact facet totals for the browse sidebar.
@@ -349,6 +382,7 @@ type SearchContacts struct {
CustomFieldFilters []SearchContactsFilter `json:"custom_field_filters"` // Custom Field Filters
CampaignIDs []string `json:"campaign_ids"` // Contacts must be in ALL these campaigns
LeadStatus string `json:"lead_status"` // Filter by derived lead status; requires exactly one campaign_id
+ Engagement string `json:"engagement"` // Filter by lead engagement (opened, not_opened, ...); ANDed with lead_status; requires exactly one campaign_id
CategoryIDs []string `json:"category_ids"` // Contacts must have ALL these categories
MinCampaigns *int `json:"min_campaigns"` // Minimum number of associated campaigns
MaxCampaigns *int `json:"max_campaigns"` // Maximum number of associated campaigns
diff --git a/internal/models/contact_export.go b/internal/models/contact_export.go
index 4d2b8f66..d36236b8 100644
--- a/internal/models/contact_export.go
+++ b/internal/models/contact_export.go
@@ -65,6 +65,12 @@ const (
ContactExportFieldCampaigns = "campaigns"
ContactExportFieldCreatedAt = "created_at"
ContactExportFieldUpdatedAt = "updated_at"
+ // Per-lead campaign engagement. Populated only when the export filters
+ // target exactly one campaign; blank otherwise.
+ ContactExportFieldLeadStatus = "lead_status"
+ ContactExportFieldLeadOpened = "lead_opened"
+ ContactExportFieldLeadClicked = "lead_clicked"
+ ContactExportFieldLeadReplied = "lead_replied"
)
// DefaultExportFields is what the UI lands on when the user just clicks
diff --git a/internal/repository/contact_engagement_live_test.go b/internal/repository/contact_engagement_live_test.go
new file mode 100644
index 00000000..cca202db
--- /dev/null
+++ b/internal/repository/contact_engagement_live_test.go
@@ -0,0 +1,142 @@
+package repository
+
+import (
+ "context"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/warmbly/warmbly/internal/models"
+)
+
+// Issue #250: the campaign Leads view filters by who opened, clicked and
+// replied on the server, so counts and pagination hold across pages. These
+// prove each `engagement` value, that a machine open is not an open, and that
+// "not opened" means sent-but-unopened rather than never-sent.
+//
+// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
+// go test ./internal/repository/ -run LiveContactEngagement -v
+
+type engagementLeads struct {
+ opened, machineOpened, clicked, replied, bounced, sentOnly, neverSent uuid.UUID
+}
+
+// seedEngagementLeads adds one email step and one lead per engagement shape.
+func seedEngagementLeads(t *testing.T, f *sharedOrgFixture, pool *pgxpool.Pool) engagementLeads {
+ t.Helper()
+ ctx := context.Background()
+ step := uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO sequences (id, campaign_id, organization_id, name, subject,
+ body_plain, body_html, wait_after, position, kind)
+ VALUES ($1, $2, $3, 'Step 1', 'Hi', 'Hello', '
Hello
', 0, 1, 'email')`, step, f.campaign, f.org); err != nil {
+ t.Fatalf("sequence: %v", err)
+ }
+ l := engagementLeads{
+ opened: addLead(t, f, "opened-"+uuid.New().String()[:6]+"@test.local", "valid", true),
+ machineOpened: addLead(t, f, "machine-"+uuid.New().String()[:6]+"@test.local", "valid", true),
+ clicked: addLead(t, f, "clicked-"+uuid.New().String()[:6]+"@test.local", "valid", true),
+ replied: addLead(t, f, "replied-"+uuid.New().String()[:6]+"@test.local", "valid", true),
+ bounced: addLead(t, f, "bounced-"+uuid.New().String()[:6]+"@test.local", "valid", true),
+ sentOnly: addLead(t, f, "sent-"+uuid.New().String()[:6]+"@test.local", "valid", true),
+ neverSent: addLead(t, f, "queued-"+uuid.New().String()[:6]+"@test.local", "valid", true),
+ }
+ progress := func(contact uuid.UUID, cols string) {
+ t.Helper()
+ if _, err := pool.Exec(ctx, `INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at`+cols+`)`,
+ f.campaign, contact, step); err != nil {
+ t.Fatalf("progress %q: %v", cols, err)
+ }
+ }
+ progress(l.opened, `, opened_at) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', NOW()`)
+ progress(l.machineOpened, `, opened_at, opened_machine) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', NOW(), true`)
+ // A click implies an open on the same step, as the tracking consumer records it.
+ progress(l.clicked, `, opened_at, clicked_at) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', NOW(), NOW()`)
+ progress(l.replied, `, replied_at) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', NOW()`)
+ progress(l.bounced, `, bounced_at) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour', NOW()`)
+ progress(l.sentOnly, `) VALUES ($1, $2, $3, NOW() - INTERVAL '1 hour'`)
+ return l
+}
+
+func searchEngagement(t *testing.T, repo *contactRepository, f *sharedOrgFixture, filters models.SearchContacts) map[uuid.UUID]models.Contact {
+ t.Helper()
+ filters.CampaignIDs = []string{f.campaign.String()}
+ res, err := repo.Search(context.Background(), f.org.String(), nil, nil, filters, 100)
+ if err != nil {
+ t.Fatalf("Search(%+v): %v", filters, err)
+ }
+ out := make(map[uuid.UUID]models.Contact, len(res.Data))
+ for _, c := range res.Data {
+ out[c.ID] = c
+ }
+ return out
+}
+
+func TestLiveContactEngagementFilters(t *testing.T) {
+ handle, pool := liveContactDB(t)
+ f := newSharedOrgFixture(t, pool)
+ l := seedEngagementLeads(t, f, pool)
+ repo := &contactRepository{DB: handle}
+
+ cases := []struct {
+ engagement string
+ want []uuid.UUID
+ }{
+ // A machine open is not an open, but the click lead's open is human.
+ {models.LeadEngagementOpened, []uuid.UUID{l.opened, l.clicked}},
+ // Sent but no human open; the never-sent lead is excluded.
+ {models.LeadEngagementNotOpened, []uuid.UUID{l.machineOpened, l.replied, l.bounced, l.sentOnly}},
+ {models.LeadEngagementClicked, []uuid.UUID{l.clicked}},
+ {models.LeadEngagementNotClicked, []uuid.UUID{l.opened, l.machineOpened, l.replied, l.bounced, l.sentOnly}},
+ {models.LeadEngagementReplied, []uuid.UUID{l.replied}},
+ {models.LeadEngagementNotReplied, []uuid.UUID{l.opened, l.machineOpened, l.clicked, l.bounced, l.sentOnly}},
+ {models.LeadEngagementBounced, []uuid.UUID{l.bounced}},
+ }
+ // Every seeded lead, plus the fixture's own contact (a lead in no campaign
+ // here, so never returned by a campaign-scoped search).
+ all := []uuid.UUID{l.opened, l.machineOpened, l.clicked, l.replied, l.bounced, l.sentOnly, l.neverSent}
+ for _, tc := range cases {
+ got := searchEngagement(t, repo, f, models.SearchContacts{Engagement: tc.engagement})
+ want := map[uuid.UUID]bool{}
+ for _, id := range tc.want {
+ want[id] = true
+ }
+ for _, id := range all {
+ if _, ok := got[id]; ok != want[id] {
+ t.Errorf("engagement=%s: lead %s returned=%v, want %v", tc.engagement, id, ok, want[id])
+ }
+ }
+ if len(got) != len(tc.want) {
+ t.Errorf("engagement=%s: %d rows, want %d", tc.engagement, len(got), len(tc.want))
+ }
+ }
+
+ // The per-lead aggregate follows the same definition of an open.
+ got := searchEngagement(t, repo, f, models.SearchContacts{})
+ if lead := got[l.machineOpened].CampaignLead; lead == nil || lead.Opened != 0 || lead.MachineOpened != 1 {
+ t.Errorf("machine-opened lead aggregate = %+v, want opened=0 machine_opened=1", lead)
+ }
+ if lead := got[l.opened].CampaignLead; lead == nil || lead.Opened != 1 || lead.MachineOpened != 0 {
+ t.Errorf("opened lead aggregate = %+v, want opened=1 machine_opened=0", lead)
+ }
+
+ // Engagement composes with lead_status as AND.
+ got = searchEngagement(t, repo, f, models.SearchContacts{LeadStatus: models.LeadStatusReplied, Engagement: models.LeadEngagementNotOpened})
+ if len(got) != 1 || got[l.replied].ID != l.replied {
+ t.Errorf("replied AND not_opened returned %d rows, want only the replied lead", len(got))
+ }
+ got = searchEngagement(t, repo, f, models.SearchContacts{LeadStatus: models.LeadStatusReplied, Engagement: models.LeadEngagementOpened})
+ if len(got) != 0 {
+ t.Errorf("replied AND opened returned %d rows, want 0", len(got))
+ }
+
+ // The campaign-wide totals the Leads chips show match the filters.
+ counts, err := repo.CampaignLeadCounts(context.Background(), f.org.String(), f.campaign.String())
+ if err != nil {
+ t.Fatalf("CampaignLeadCounts: %v", err)
+ }
+ if counts.Contacted != 6 || counts.Opened != 2 || counts.Clicked != 1 || counts.RepliedAny != 1 {
+ t.Errorf("lead counts = contacted %d opened %d clicked %d replied_any %d, want 6/2/1/1",
+ counts.Contacted, counts.Opened, counts.Clicked, counts.RepliedAny)
+ }
+}
diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go
index 89cc5834..fca5b587 100644
--- a/internal/repository/pg_contact.go
+++ b/internal/repository/pg_contact.go
@@ -751,6 +751,13 @@ func (r *contactRepository) Search(
whereClauses = append(whereClauses, clause)
}
}
+ // Engagement composes with lead_status as AND, so "replied AND not_opened"
+ // is a valid (if odd) combination rather than one overriding the other.
+ if filters.Engagement != "" && singleCampaignPlaceholder != "" {
+ if clause := leadEngagementClause(filters.Engagement, singleCampaignPlaceholder); clause != "" {
+ whereClauses = append(whereClauses, clause)
+ }
+ }
// -----------------------------
// Category IDs filter (must have ALL specified categories)
@@ -862,7 +869,10 @@ func (r *contactRepository) Search(
leadProgressSelect = fmt.Sprintf(`(
SELECT json_build_object(
'sent', COUNT(*) FILTER (WHERE p.sent_at IS NOT NULL),
- 'opened', COUNT(*) FILTER (WHERE p.opened_at IS NOT NULL),
+ -- Human opens only; automated fetches (Apple MPP prefetch, UA-less
+ -- clients) are counted apart so they never read as engagement.
+ 'opened', COUNT(*) FILTER (WHERE p.opened_at IS NOT NULL AND NOT p.opened_machine),
+ 'machine_opened', COUNT(*) FILTER (WHERE p.opened_at IS NOT NULL AND p.opened_machine),
'clicked', COUNT(*) FILTER (WHERE p.clicked_at IS NOT NULL),
'replied', COUNT(*) FILTER (WHERE p.replied_at IS NOT NULL),
'bounced', COUNT(*) FILTER (WHERE p.bounced_at IS NOT NULL),
@@ -1015,6 +1025,7 @@ func (r *contactRepository) Search(
var lp struct {
Sent int `json:"sent"`
Opened int `json:"opened"`
+ MachineOpn int `json:"machine_opened"`
Clicked int `json:"clicked"`
Replied int `json:"replied"`
Bounced int `json:"bounced"`
@@ -1063,6 +1074,7 @@ func (r *contactRepository) Search(
Status: status,
Sent: lp.Sent,
Opened: lp.Opened,
+ MachineOpened: lp.MachineOpn,
Clicked: lp.Clicked,
Replied: lp.Replied,
Bounced: lp.Bounced,
@@ -1272,6 +1284,44 @@ func leadStatusClause(status, cp string) string {
// completed > processing > undeliverable > queued priority as the row-level
// derived status.
// Scoped to the org through the contacts join.
+// leadEngagementClause builds the WHERE predicate for one engagement filter
+// value inside ONE campaign (`cp` is that campaign's bound placeholder). An
+// open counts only when it is human: opened_machine marks automated fetches,
+// the same split the analytics summary reports as machine opens. The negative
+// forms require at least one sent step, so a lead never emailed matches
+// neither side. Returns "" for an unknown value.
+func leadEngagementClause(engagement, cp string) string {
+ has := func(cond string) string {
+ return fmt.Sprintf(
+ "EXISTS (SELECT 1 FROM campaign_contact_progress p WHERE p.campaign_id = %s AND p.contact_id = c.id AND %s)",
+ cp, cond,
+ )
+ }
+ sent := has("p.sent_at IS NOT NULL")
+ opened := has("p.opened_at IS NOT NULL AND NOT p.opened_machine")
+ clicked := has("p.clicked_at IS NOT NULL")
+ replied := has("p.replied_at IS NOT NULL")
+ bounced := has("p.bounced_at IS NOT NULL")
+ switch engagement {
+ case models.LeadEngagementOpened:
+ return opened
+ case models.LeadEngagementNotOpened:
+ return fmt.Sprintf("(%s AND NOT %s)", sent, opened)
+ case models.LeadEngagementClicked:
+ return clicked
+ case models.LeadEngagementNotClicked:
+ return fmt.Sprintf("(%s AND NOT %s)", sent, clicked)
+ case models.LeadEngagementReplied:
+ return replied
+ case models.LeadEngagementNotReplied:
+ return fmt.Sprintf("(%s AND NOT %s)", sent, replied)
+ case models.LeadEngagementBounced:
+ return bounced
+ default:
+ return ""
+ }
+}
+
func (r *contactRepository) CampaignLeadCounts(ctx context.Context, orgID, campaignID string) (*models.CampaignLeadCounts, *errx.Error) {
// A lead is "done" (completed) when every email step has been sent and it
// hasn't replied or bounced; "processing" when some but not all steps sent.
@@ -1287,7 +1337,11 @@ func (r *contactRepository) CampaignLeadCounts(ctx context.Context, orgID, campa
COUNT(*) FILTER (WHERE %[2]s AND COALESCE(pr.has_sent, false) AND (%[1]s)) AS completed,
COUNT(*) FILTER (WHERE %[2]s AND COALESCE(pr.has_sent, false) AND NOT (%[1]s)) AS processing,
COUNT(*) FILTER (WHERE %[2]s AND NOT COALESCE(pr.has_sent, false) AND %[3]s) AS undeliverable,
- COUNT(*) FILTER (WHERE %[2]s AND NOT COALESCE(pr.has_sent, false) AND NOT %[3]s) AS queued
+ COUNT(*) FILTER (WHERE %[2]s AND NOT COALESCE(pr.has_sent, false) AND NOT %[3]s) AS queued,
+ COUNT(*) FILTER (WHERE COALESCE(pr.has_sent, false)) AS contacted,
+ COUNT(*) FILTER (WHERE COALESCE(pr.has_opened, false)) AS opened,
+ COUNT(*) FILTER (WHERE COALESCE(pr.has_clicked, false)) AS clicked,
+ COUNT(*) FILTER (WHERE COALESCE(pr.has_replied, false)) AS replied_any
FROM campaign_leads cl
JOIN contacts c ON c.id = cl.contact_id AND c.organization_id = $2
CROSS JOIN (SELECT COUNT(*) AS total_steps FROM sequences st WHERE st.campaign_id = $1 AND st.kind = 'email') ts
@@ -1296,6 +1350,8 @@ func (r *contactRepository) CampaignLeadCounts(ctx context.Context, orgID, campa
bool_or(p.sent_at IS NOT NULL) AS has_sent,
bool_or(p.replied_at IS NOT NULL) AS has_replied,
bool_or(p.bounced_at IS NOT NULL) AS has_bounced,
+ bool_or(p.opened_at IS NOT NULL AND NOT p.opened_machine) AS has_opened,
+ bool_or(p.clicked_at IS NOT NULL) AS has_clicked,
bool_or(p.sent_at IS NULL AND p.failed_at IS NOT NULL AND p.send_attempts >= $3) AS has_failed,
COUNT(*) FILTER (WHERE p.sent_at IS NOT NULL) AS sent_steps
FROM campaign_contact_progress p
@@ -1306,6 +1362,7 @@ func (r *contactRepository) CampaignLeadCounts(ctx context.Context, orgID, campa
out := &models.CampaignLeadCounts{}
if err := r.DB.QueryRow(ctx, query, campaignID, orgID, config.CampaignSendMaxAttempts).Scan(
&out.Total, &out.Unsubscribed, &out.Bounced, &out.Replied, &out.Failed, &out.Completed, &out.Processing, &out.Undeliverable, &out.Queued,
+ &out.Contacted, &out.Opened, &out.Clicked, &out.RepliedAny,
); err != nil {
if err == pgx.ErrNoRows {
return out, nil
diff --git a/web/src/components/app/contacts/ContactFilters.tsx b/web/src/components/app/contacts/ContactFilters.tsx
index f32c98bb..2cd73b37 100644
--- a/web/src/components/app/contacts/ContactFilters.tsx
+++ b/web/src/components/app/contacts/ContactFilters.tsx
@@ -13,6 +13,7 @@ import type SearchContacts from "@/lib/api/models/app/contacts/SearchContacts";
import type SearchContactsFilter from "@/lib/api/models/app/contacts/SearchContactsFilter";
import type { SearchContactsFilterType, SearchContactsSortBy } from "@/lib/api/models/app/contacts/search-contacts.types";
import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign";
+import type { LeadEngagement, LeadStatus } from "@/lib/api/models/app/contacts/Contact";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
@@ -266,6 +267,31 @@ export default function ContactFilters({
+ Opens count people, not mail clients: automatic prefetches are ignored. Not opened, not clicked and not replied only cover leads that have been sent at least one email.
+