Merge remote-tracking branch 'origin/main' into fix888

This commit is contained in:
Matthew Meszaros
2026-10-09 06:40:54 +02:00
45 changed files with 1690 additions and 122 deletions
+10 -1
View File
@@ -1741,7 +1741,16 @@ func analyticsSpec() resource {
Short: "The numbers: sends, opens, replies, deliverability, warmup",
Group: groupData,
Endpoints: []endpoint{
{Name: "dashboard", Short: "The headline numbers", Method: http.MethodGet, Path: "/analytics/dashboard"},
{
Name: "dashboard", Short: "The headline numbers, for the workspace or chosen campaigns and folders",
Method: http.MethodGet, Path: "/analytics/dashboard",
Example: " $ warmbly analytics dashboard --period 30d --folders <folder-id> --campaigns <id>,<id>",
Flag: []flagSpec{
{Name: "period", Help: "7d, 30d or 90d", Query: true},
{Name: "campaigns", Help: "Only these campaigns, comma-separated ids", Query: true, Key: "campaign_ids"},
{Name: "folders", Help: "Only the campaigns in these folders, comma-separated ids", Query: true, Key: "folder_ids"},
},
},
{Name: "deliverability", Short: "Bounces, complaints and placement", Method: http.MethodGet, Path: "/analytics/deliverability"},
{
Name: "warmup", Short: "Warmup analytics over a date range",
+1 -1
View File
@@ -141,7 +141,7 @@ var apiSpecs = []apiSpec{
{name: "inbox cancel-scheduled", summary: "Cancel a scheduled send", method: "DELETE", path: "/unibox/scheduled/{id}"},
// Analytics and audit.
{name: "analytics dashboard", summary: "The dashboard numbers", method: "GET", path: "/analytics/dashboard"},
{name: "analytics dashboard", summary: "The dashboard numbers, optionally for chosen campaigns and folders", method: "GET", path: "/analytics/dashboard", query: []string{"period", "campaign_ids", "folder_ids"}},
{name: "analytics deliverability", summary: "Bounces, complaints and placement", method: "GET", path: "/analytics/deliverability"},
{name: "analytics warmup", summary: "Warmup analytics", method: "GET", path: "/analytics/warmup"},
{name: "analytics accounts", summary: "Per-mailbox analytics", method: "GET", path: "/analytics/accounts"},
+1 -1
View File
@@ -109,7 +109,7 @@ Every tool is gated by its API permission. `tools/list` returns only the tools y
| `create_campaign_draft` | Create a draft campaign | `WRITE_CAMPAIGNS` |
| `set_campaign_status` | Start or stop a campaign | `SEND_CAMPAIGNS` |
| `set_lead_hold` | Hold or resume one contact's flow in one campaign, without unsubscribing them | `WRITE_CAMPAIGNS` |
| `get_dashboard_analytics` | Org-wide sending stats for a period | `READ_ANALYTICS` |
| `get_dashboard_analytics` | Sending stats for a period, org-wide or for chosen campaigns and campaign folders | `READ_ANALYTICS` |
| `list_mailboxes` | Sender mailboxes with health + warmup state | `READ_ANALYTICS` |
| `list_placement_tests` / `get_placement_test` | Inbox placement tests and where each copy landed | `READ_ANALYTICS` |
| `run_placement_test` | Send a step or template to the seed inboxes from one mailbox | `SEND_CAMPAIGNS` |
@@ -18,6 +18,28 @@ Returns the main dashboard overview for the active organization: aggregate stats
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `period` | query | string | One of `7d`, `30d`, `90d`, measured as UTC calendar days including today. Defaults to `7d`; any other value falls back to `7d`. |
| `campaign_ids` | query | string | Optional. Comma-separated campaign ids (or the parameter repeated) to narrow the campaign sections to. At most `100`. |
| `folder_ids` | query | string | Optional. Comma-separated campaign folder ids. Each one stands for the campaigns in that folder when the request is made. At most `100`. |
### Filtering by campaign or folder
Without `campaign_ids` and `folder_ids` the dashboard covers every campaign in the workspace. With either, the server resolves them to one set: the named campaigns plus every campaign currently in a named folder. A campaign named both ways, or sitting in two named folders, is counted once.
The filter narrows everything built from campaign sends: `overall_stats` (sends, opens, clicks, replies, bounces, their rates and `active_campaigns`), `daily_trend`, `top_campaigns` and `recent_activity`. It does not narrow `account_health`, `overall_stats.active_accounts` or `capacity_today`, which describe the workspace's mailboxes, or [direct mail](/guides/analytics/#direct-mail), which belongs to no campaign.
An id that does not belong to the active organization, or no longer exists, is dropped and never widens the filter: if nothing named belongs to the workspace, every campaign figure is zero. A malformed id, or more than `100` of either, is a `400`. A filtered response echoes what it was computed for in `scope`:
```json
{
"scope": {
"campaigns": [{ "id": "b1f2c3d4-0000-0000-0000-000000000001", "name": "Client A - Dentists" }],
"folders": [{ "id": "f01d0000-0000-0000-0000-000000000004", "name": "Client A" }],
"campaign_count": 6
}
}
```
`campaigns` and `folders` list the requested ones that belong to the workspace; `campaign_count` is how many distinct campaigns they cover together. `scope` is absent when the request was not filtered.
### Response
+3 -1
View File
@@ -319,7 +319,7 @@ Sends or schedules a reply or a forward from any mailbox in the organization. Th
| `body_html` | string | No | HTML body. |
| `body_plain` | string | No | Plain-text body. Leave it empty with `body_html` set and the plain-text part is rendered from the HTML, link destinations included. |
| `in_reply_to` | string[] | No | Message-ID(s) this reply threads under. When omitted, the newest Message-ID in `thread_id` is used. |
| `thread_id` | string | No | The conversation being answered. |
| `thread_id` | string | No | The conversation being answered. Not used with `forward_message_id`; see [Forwarding a message](#forwarding-a-message). |
| `send_mode` | string | No | `instant` (default), `smart` (next mailbox gap), or `scheduled` (use `scheduled_at`). |
| `scheduled_at` | string | No | RFC 3339 timestamp. Required when `send_mode` is `scheduled`; must be in the future. |
| `forward_message_id` | string | No | UUID of a stored message (the `id` from `GET /unibox/:id`) to forward. See [Forwarding a message](#forwarding-a-message). |
@@ -360,6 +360,8 @@ Set `forward_message_id` to forward a message from the inbox. Warmbly attaches t
The Date line is written in the sending mailbox's time zone (its own, else the workspace's, else UTC). The original HTML keeps its formatting: its stylesheet is folded into inline styles and it is sanitized like a message opened in Unibox, so scripts, event handlers and Warmbly's own open-tracking pixels are dropped. When the original has only a text part, it is forwarded as text. Links in the forwarded message are never rewritten for click tracking; only your note is tracked, when the mailbox tracks direct mail. Forwarding one of your own tracked sends (from Sent, say) swaps its tracked links back to their real destinations, so the new recipient's clicks are not counted against the original send.
A forward goes out as a new conversation for its recipient: no provider thread, and no In-Reply-To or References unless you set `in_reply_to` yourself. A `thread_id` sent with it is not used for either. Inside Unibox the forward belongs to the conversation of the message it forwards (that message's `thread_id`), whichever mailbox sends it: it is listed with that conversation's scheduled sends while queued, and once the sending mailbox's Sent folder syncs, the sent copy is returned by `GET /unibox/thread` for that conversation, stored once. A forward in a conversation is never picked as the parent Message-ID for a later reply into it.
Two limits:
- attachments on the original are not forwarded
+11 -1
View File
@@ -32,7 +32,7 @@ Seven rules govern the counts:
## Workspace dashboard
Live, scoped by `7d`, `30d`, or `90d` tabs.
Live, scoped by `7d`, `30d`, or `90d` tabs, and by default covering every campaign in the workspace.
| Section | Shows |
|---------|-------|
@@ -45,6 +45,16 @@ Live, scoped by `7d`, `30d`, or `90d` tabs.
Sending totals, trends, comparisons, and top-campaign rates count email steps only. Completed wait and action steps move a sequence forward but never inflate the number of messages sent.
### Filtering by campaign or folder
The **All campaigns** menu next to the period tabs narrows the dashboard to the campaigns you pick. Choose one campaign, several, one or more [campaign folders](/guides/campaigns/), or a mix. The figures add the selected campaigns together, and a campaign picked directly and through a folder, or sitting in two picked folders, is counted once. A folder stands for the campaigns in it right now, so moving a campaign in or out of the folder changes what the dashboard counts the next time it loads. The period tabs keep working on top of the filter, and the selection is remembered in your browser tab like the period.
| Follows the filter | Stays workspace-wide |
| --- | --- |
| Headline rates, Email performance chart, Breakdown, Top campaigns, Recent activity | Account health and Direct mail, marked **Workspace-wide** and **Not filtered by campaign** while a filter is on, because neither is campaign data |
A campaign or folder that is deleted while selected drops out of the filter on its own. The mobile app's Analytics overview has the same menu under its period picker; there the filter changes the overview's campaign figures and leaves the Deliverability, Warmup and Accounts sections as they are. Through the API, pass `campaign_ids` and `folder_ids` to [`GET /analytics/dashboard`](/api/reference/analytics/#get-dashboard-analytics).
## Direct mail
The rest of this page measures campaigns. **Direct mail** measures what you write by hand in the [unified inbox](/guides/unified-inbox/), which most workspaces do far more of than they expect.
+2
View File
@@ -221,6 +221,8 @@ A reply from a different mailbox keeps the conversation's subject and its In-Rep
The composer shows the message it will forward under your note; expand **Forwarded message** to read it before you send. Attachments on the original are not forwarded. If only a preview of the original is stored (the thread shows "Only a preview of this message is stored"), the composer says so, and the forward carries that preview. Forwarding one of your own tracked sends puts its links back to their real destinations, so the new recipient's clicks do not count against the original send.
Once sent, the forward shows in the conversation you forwarded from, as the next outgoing message with its forwarded block, whichever mailbox sent it. While it waits to go out (the undo window, or a scheduled time) it is listed with the conversation's scheduled sends. It appears once the sending mailbox's Sent folder syncs, and that sync files the copy into this conversation rather than adding a second one. Only Warmbly files it there: the recipient gets an ordinary forward that starts a conversation of its own, with no In-Reply-To or References pointing at the original, and a later reply in the conversation still answers its last message from the other side, never the forward.
### Pausing their follow-ups
The contact panel lists every campaign the sender is a lead of, with their status in it and what happens next: the next step and when, the hold and when it lifts, or why the flow ended. A campaign with a step still to send has **Pause**, which opens the same dialog as the campaign's Leads list (until a date, or until you resume them, with an optional note). A held lead has **Resume** instead; for a contact held because they are [copied on another lead's emails](/guides/campaigns/#copying-colleagues-on-one-lead) it asks first, since it starts a second thread to them. Neither appears on a lead whose flow has ended, and both need the **Manage campaigns** permission. See [Out of office and pausing one lead](/guides/campaigns/#out-of-office-and-pausing-one-lead).
+67 -1
View File
@@ -15112,7 +15112,7 @@
"analytics"
],
"summary": "Get dashboard analytics",
"description": "Org-wide dashboard overview: aggregate stats, recent activity, top campaigns, account health, and a daily trend series.",
"description": "Org-wide dashboard overview: aggregate stats, recent activity, top campaigns, account health, and a daily trend series. campaign_ids and folder_ids narrow the campaign sections; account health stays org-wide.",
"security": [
{
"bearerAuth": []
@@ -15133,6 +15133,24 @@
],
"default": "7d"
}
},
{
"name": "campaign_ids",
"in": "query",
"required": false,
"description": "Comma-separated campaign ids to narrow the campaign sections to, unioned with folder_ids and counted once each. Ids outside the organization are dropped. At most 100.",
"schema": {
"type": "string"
}
},
{
"name": "folder_ids",
"in": "query",
"required": false,
"description": "Comma-separated campaign folder ids; each stands for the campaigns in that folder at request time. Ids outside the organization are dropped. At most 100.",
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -36358,6 +36376,54 @@
"items": {
"$ref": "#/components/schemas/DailyTrendPoint"
}
},
"scope": {
"$ref": "#/components/schemas/DashboardScope"
}
}
},
"DashboardScope": {
"type": "object",
"description": "The campaign filter a dashboard response was computed for. Absent when the request was not filtered.",
"required": [
"campaigns",
"folders",
"campaign_count"
],
"properties": {
"campaigns": {
"type": "array",
"description": "Requested campaigns that belong to the organization.",
"items": {
"$ref": "#/components/schemas/DashboardScopeItem"
}
},
"folders": {
"type": "array",
"description": "Requested campaign folders that belong to the organization.",
"items": {
"$ref": "#/components/schemas/DashboardScopeItem"
}
},
"campaign_count": {
"type": "integer",
"description": "Distinct campaigns the campaigns and folders cover together."
}
}
},
"DashboardScopeItem": {
"type": "object",
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
}
}
},
+42 -2
View File
@@ -1,6 +1,7 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"strings"
@@ -347,8 +348,12 @@ func (h *Handler) GetRealtimeInfo(c *gin.Context) {
})
}
// maxDashboardScopeIDs bounds each of campaign_ids and folder_ids; a folder
// stands in for any number of campaigns, so a larger set is never needed.
const maxDashboardScopeIDs = 100
// GetDashboardAnalytics returns main dashboard analytics overview
// GET /analytics/dashboard?period=7d
// GET /analytics/dashboard?period=7d[&campaign_ids=a,b][&folder_ids=c]
func (h *Handler) GetDashboardAnalytics(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
@@ -362,7 +367,18 @@ func (h *Handler) GetDashboardAnalytics(c *gin.Context) {
period = "7d"
}
analytics, xerr := h.AnalyticsService.GetDashboardAnalytics(c.Request.Context(), *orgID, period)
var filter models.DashboardFilter
var xerr *errx.Error
if filter.CampaignIDs, xerr = uuidListQuery(c, "campaign_ids", maxDashboardScopeIDs); xerr != nil {
errx.Handle(c, xerr)
return
}
if filter.FolderIDs, xerr = uuidListQuery(c, "folder_ids", maxDashboardScopeIDs); xerr != nil {
errx.Handle(c, xerr)
return
}
analytics, xerr := h.AnalyticsService.GetDashboardAnalytics(c.Request.Context(), *orgID, period, filter)
if xerr != nil {
errx.Handle(c, xerr)
return
@@ -481,6 +497,30 @@ func (h *Handler) CompareCampaigns(c *gin.Context) {
c.JSON(http.StatusOK, comparison)
}
// uuidListQuery reads a comma-separated or repeated query parameter of
// UUIDs, deduplicated in order. A malformed id or more than max is a 400.
func uuidListQuery(c *gin.Context, key string, max int) ([]uuid.UUID, *errx.Error) {
var ids []uuid.UUID
seen := make(map[uuid.UUID]struct{})
for _, raw := range c.QueryArray(key) {
for _, part := range splitAndTrim(raw) {
id, err := uuid.Parse(part)
if err != nil {
return nil, errx.New(errx.BadRequest, key+" must be a comma-separated list of UUIDs")
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
}
if len(ids) > max {
return nil, errx.New(errx.BadRequest, fmt.Sprintf("%s accepts at most %d ids", key, max))
}
return ids, nil
}
// splitAndTrim splits a comma-separated string and trims whitespace
func splitAndTrim(s string) []string {
if s == "" {
@@ -56,3 +56,44 @@ func TestOptionalDayRange(t *testing.T) {
})
}
}
// GET /analytics/dashboard takes campaign_ids and folder_ids as comma lists
// or repeated keys, deduplicated, refusing a malformed id (issue #869).
func TestUUIDListQuery(t *testing.T) {
gin.SetMode(gin.TestMode)
a, b := "0b8f6a52-6f0e-4c3e-9a51-0d3f1b0f6a01", "0b8f6a52-6f0e-4c3e-9a51-0d3f1b0f6a02"
for _, tc := range []struct {
query string
want int
wantErr bool
}{
{query: "", want: 0},
{query: "campaign_ids=" + a, want: 1},
{query: "campaign_ids=" + a + "," + b, want: 2},
{query: "campaign_ids=" + a + "&campaign_ids=" + b, want: 2},
{query: "campaign_ids=" + a + ",%20" + a + ",", want: 1},
{query: "campaign_ids=" + a + ",nope", wantErr: true},
{query: "campaign_ids=" + a + "," + b + "," + a, want: 2},
} {
t.Run(tc.query, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/analytics/dashboard?"+tc.query, nil)
ids, xerr := uuidListQuery(c, "campaign_ids", 2)
if tc.wantErr {
if xerr == nil {
t.Fatalf("ids = %v, want a 400", ids)
}
return
}
if xerr != nil || len(ids) != tc.want {
t.Fatalf("ids = %v, err = %v; want %d ids", ids, xerr, tc.want)
}
})
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/analytics/dashboard?campaign_ids="+a+","+b, nil)
if ids, xerr := uuidListQuery(c, "campaign_ids", 1); xerr == nil {
t.Fatalf("ids = %v past the limit, want a 400", ids)
}
}
+3 -2
View File
@@ -612,9 +612,10 @@ func (h *Handler) UniboxReply(c *gin.Context) {
// The composer only knows the provider thread id, but a thread id is
// meaningless outside the sending mailbox: the recipient's client threads
// on In-Reply-To/References. Resolve the parent Message-ID here so a reply
// nests for them too, without needing a client change.
// nests for them too, without needing a client change. A forward answers
// nobody: it is filed into the forwarded message's conversation instead.
inReplyTo := req.InReplyTo
if len(inReplyTo) == 0 && req.ThreadID != "" {
if len(inReplyTo) == 0 && req.ThreadID != "" && forward == nil {
if parentMessageID, xerr := h.UniboxService.LatestMessageIDInThread(c.Request.Context(), *orgID, req.ThreadID); xerr == nil && mailhdr.ValidMessageID(parentMessageID) {
inReplyTo = []string{parentMessageID}
}
@@ -91,3 +91,64 @@ func TestUniboxReplyForwardNeedsReadAccess(t *testing.T) {
})
}
}
type fakeForwardThreadUnibox struct {
fakeForwardUnibox
parentLookups int
}
func (f *fakeForwardThreadUnibox) LatestMessageIDInThread(context.Context, uuid.UUID, string) (string, *errx.Error) {
f.parentLookups++
return "<parent@example.com>", nil
}
// A forward from an open conversation answers nobody, so no In-Reply-To is
// resolved from the thread it was forwarded from; a reply still gets one.
func TestUniboxReplyForwardFromAThreadIsNoReply(t *testing.T) {
gin.SetMode(gin.TestMode)
sender := uuid.New()
for _, tc := range []struct {
name string
forward bool
parent bool
}{
{"forward", true, false},
{"reply", false, true},
} {
t.Run(tc.name, func(t *testing.T) {
ub := &fakeForwardThreadUnibox{fakeForwardUnibox: fakeForwardUnibox{mailbox: sender}}
send := &fakeForwardSend{}
h := &Handler{UniboxService: ub, EmailSendService: send, AuditService: audit.NewNoOpService()}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.UserIDKey, uuid.NewString())
c.Set(middleware.OrganizationIDKey, uuid.New())
c.Set(middleware.AuthTypeKey, middleware.AuthTypeAPIKey)
c.Set(middleware.APIKeyPermissionsKey, models.APIPermWriteUnibox|models.APIPermReadUnibox)
})
router.POST("/unibox/reply", h.UniboxReply)
body := `{"email_account_id":"` + sender.String() + `","to":["team@example.com"],"subject":"Fwd: Pricing","body_plain":"FYI","thread_id":"conversation-1"`
if tc.forward {
body += `,"forward_message_id":"` + uuid.NewString() + `"`
}
body += `}`
req := httptest.NewRequest(http.MethodPost, "/unibox/reply", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if got := len(send.req.InReplyTo) > 0; got != tc.parent {
t.Fatalf("in_reply_to %v, want a parent: %v", send.req.InReplyTo, tc.parent)
}
if (ub.parentLookups > 0) != tc.parent {
t.Fatalf("parent looked up %d times", ub.parentLookups)
}
})
}
}
+24 -4
View File
@@ -11,9 +11,11 @@ import (
func (d Deps) registerAnalyticsTools(r *Registry) {
r.Register(Tool{
Name: "get_dashboard_analytics",
Description: "Org-wide sending analytics for a period: totals (sent, opens, clicks, replies, bounces), rates, top campaigns, and account-health summary. Use this for \"how are we doing\" questions.",
Description: "Sending analytics for a period: totals (sent, opens, clicks, replies, bounces), rates, top campaigns, and account-health summary. Org-wide by default, or for chosen campaigns and campaign folders together. Use this for \"how are we doing\" questions.",
InputSchema: objectSchema(map[string]any{
"period": enumProp("Aggregation window (default 30d).", "7d", "30d", "90d"),
"period": enumProp("Aggregation window (default 30d).", "7d", "30d", "90d"),
"campaign_ids": arrProp("Only these campaigns. Combined with folder_ids, each campaign counts once.", strProp("Campaign id")),
"folder_ids": arrProp("Only the campaigns currently in these campaign folders.", strProp("Folder id")),
}),
Risk: generation.RiskRead,
RequiredOrgPerm: models.PermViewAnalytics,
@@ -34,7 +36,9 @@ func (d Deps) registerAnalyticsTools(r *Registry) {
func (d Deps) getDashboardAnalytics(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
in, err := decodeArgs[struct {
Period string `json:"period"`
Period string `json:"period"`
CampaignIDs []string `json:"campaign_ids"`
FolderIDs []string `json:"folder_ids"`
}](args)
if err != nil {
return "", err
@@ -43,7 +47,22 @@ func (d Deps) getDashboardAnalytics(ctx context.Context, inv Invocation, args js
if period == "" {
period = "30d"
}
a, xerr := d.Analytics.GetDashboardAnalytics(ctx, inv.OrgID, period)
var filter models.DashboardFilter
for _, raw := range in.CampaignIDs {
id, perr := parseUUIDArg(raw)
if perr != nil {
return "", perr
}
filter.CampaignIDs = append(filter.CampaignIDs, id)
}
for _, raw := range in.FolderIDs {
id, perr := parseUUIDArg(raw)
if perr != nil {
return "", perr
}
filter.FolderIDs = append(filter.FolderIDs, id)
}
a, xerr := d.Analytics.GetDashboardAnalytics(ctx, inv.OrgID, period, filter)
if xerr != nil {
return "", fromErrx(xerr)
}
@@ -72,6 +91,7 @@ func (d Deps) getDashboardAnalytics(ctx context.Context, inv Invocation, args js
},
"top_campaigns": top,
"account_health": a.AccountHealth,
"scope": a.Scope,
})
}
+20 -6
View File
@@ -45,7 +45,9 @@ type AnalyticsService interface {
GetUsageOverview(ctx context.Context, orgID, userID uuid.UUID, period string) (*models.UsageOverview, *errx.Error)
// Dashboard analytics
GetDashboardAnalytics(ctx context.Context, orgID uuid.UUID, period string) (*models.DashboardAnalytics, *errx.Error)
// GetDashboardAnalytics narrows the campaign-derived sections to filter
// when it names anything; account health stays workspace-wide.
GetDashboardAnalytics(ctx context.Context, orgID uuid.UUID, period string, filter models.DashboardFilter) (*models.DashboardAnalytics, *errx.Error)
GetDirectMailAnalytics(ctx context.Context, orgID uuid.UUID, period string) (*models.DirectMailAnalytics, *errx.Error)
GetCampaignHourlyStats(ctx context.Context, orgID, campaignID uuid.UUID, date time.Time) ([]models.CampaignHourlyStats, *errx.Error)
CompareCampaigns(ctx context.Context, orgID uuid.UUID, campaignIDs []uuid.UUID, from, to time.Time) (*models.CampaignComparison, *errx.Error)
@@ -714,7 +716,7 @@ func (s *analyticsService) warmupPartnerLimit(ctx context.Context, email *models
// Dashboard Analytics implementations
func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid.UUID, period string) (*models.DashboardAnalytics, *errx.Error) {
func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid.UUID, period string, filter models.DashboardFilter) (*models.DashboardAnalytics, *errx.Error) {
// Calculate date range from period
to := time.Now().UTC()
startOfToday := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, time.UTC)
@@ -732,20 +734,31 @@ func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid
period = "7d"
}
// The one place a filter becomes a campaign set, so every section reads the same one.
var view *models.DashboardScope
var scope *models.CampaignScope
if !filter.Empty() {
var xerr *errx.Error
view, scope, xerr = s.analyticsRepo.ResolveDashboardScope(ctx, orgID, filter)
if xerr != nil {
return nil, xerr
}
}
// Get overall stats
overallStats, xerr := s.analyticsRepo.GetDashboardOverallStats(ctx, orgID, from, to)
overallStats, xerr := s.analyticsRepo.GetDashboardOverallStats(ctx, orgID, from, to, scope)
if xerr != nil {
return nil, xerr
}
// Get recent activity
recentActivity, xerr := s.analyticsRepo.GetRecentActivity(ctx, orgID, 20)
recentActivity, xerr := s.analyticsRepo.GetRecentActivity(ctx, orgID, 20, scope)
if xerr != nil {
recentActivity = make([]models.RecentActivityItem, 0)
}
// Get top campaigns
topCampaigns, xerr := s.analyticsRepo.GetTopCampaigns(ctx, orgID, from, to, 5, "emails_sent")
topCampaigns, xerr := s.analyticsRepo.GetTopCampaigns(ctx, orgID, from, to, 5, "emails_sent", scope)
if xerr != nil {
topCampaigns = make([]models.TopCampaignStats, 0)
}
@@ -757,7 +770,7 @@ func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid
}
// Get daily trend
dailyTrend, xerr := s.analyticsRepo.GetDashboardDailyTrend(ctx, orgID, from, to)
dailyTrend, xerr := s.analyticsRepo.GetDashboardDailyTrend(ctx, orgID, from, to, scope)
if xerr != nil {
dailyTrend = make([]models.DashboardDailyStats, 0)
}
@@ -769,6 +782,7 @@ func (s *analyticsService) GetDashboardAnalytics(ctx context.Context, orgID uuid
TopCampaigns: topCampaigns,
AccountHealth: *accountHealth,
DailyTrend: dailyTrend,
Scope: view,
}, nil
}
+78 -5
View File
@@ -53,18 +53,38 @@ type dashboardAnalyticsRepoStub struct {
overallTo time.Time
trendFrom time.Time
trendTo time.Time
// resolved is what ResolveDashboardScope answers; scopes records what
// each section was then asked for, keyed by section.
resolved *models.CampaignScope
resolves int
scopes map[string]*models.CampaignScope
}
func (s *dashboardAnalyticsRepoStub) GetDashboardOverallStats(_ context.Context, _ uuid.UUID, from, to time.Time) (*models.DashboardOverallStats, *errx.Error) {
func (s *dashboardAnalyticsRepoStub) record(section string, scope *models.CampaignScope) {
if s.scopes == nil {
s.scopes = map[string]*models.CampaignScope{}
}
s.scopes[section] = scope
}
func (s *dashboardAnalyticsRepoStub) ResolveDashboardScope(_ context.Context, _ uuid.UUID, _ models.DashboardFilter) (*models.DashboardScope, *models.CampaignScope, *errx.Error) {
s.resolves++
return &models.DashboardScope{CampaignCount: len(s.resolved.CampaignIDs)}, s.resolved, nil
}
func (s *dashboardAnalyticsRepoStub) GetDashboardOverallStats(_ context.Context, _ uuid.UUID, from, to time.Time, scope *models.CampaignScope) (*models.DashboardOverallStats, *errx.Error) {
s.overallFrom, s.overallTo = from, to
s.record("overall", scope)
return &models.DashboardOverallStats{}, nil
}
func (*dashboardAnalyticsRepoStub) GetRecentActivity(context.Context, uuid.UUID, int) ([]models.RecentActivityItem, *errx.Error) {
func (s *dashboardAnalyticsRepoStub) GetRecentActivity(_ context.Context, _ uuid.UUID, _ int, scope *models.CampaignScope) ([]models.RecentActivityItem, *errx.Error) {
s.record("recent", scope)
return []models.RecentActivityItem{}, nil
}
func (*dashboardAnalyticsRepoStub) GetTopCampaigns(context.Context, uuid.UUID, time.Time, time.Time, int, string) ([]models.TopCampaignStats, *errx.Error) {
func (s *dashboardAnalyticsRepoStub) GetTopCampaigns(_ context.Context, _ uuid.UUID, _, _ time.Time, _ int, _ string, scope *models.CampaignScope) ([]models.TopCampaignStats, *errx.Error) {
s.record("top", scope)
return []models.TopCampaignStats{}, nil
}
@@ -72,8 +92,9 @@ func (*dashboardAnalyticsRepoStub) GetAccountHealthSummary(context.Context, uuid
return &models.AccountHealthSummary{}, nil
}
func (s *dashboardAnalyticsRepoStub) GetDashboardDailyTrend(_ context.Context, _ uuid.UUID, from, to time.Time) ([]models.DashboardDailyStats, *errx.Error) {
func (s *dashboardAnalyticsRepoStub) GetDashboardDailyTrend(_ context.Context, _ uuid.UUID, from, to time.Time, scope *models.CampaignScope) ([]models.DashboardDailyStats, *errx.Error) {
s.trendFrom, s.trendTo = from, to
s.record("trend", scope)
return []models.DashboardDailyStats{}, nil
}
@@ -139,7 +160,7 @@ func TestDashboardUsesTheSameSevenCalendarDaysForCardsAndTrend(t *testing.T) {
repo := &dashboardAnalyticsRepoStub{}
svc := &analyticsService{analyticsRepo: repo}
got, xerr := svc.GetDashboardAnalytics(context.Background(), uuid.New(), "7d")
got, xerr := svc.GetDashboardAnalytics(context.Background(), uuid.New(), "7d", models.DashboardFilter{})
if xerr != nil {
t.Fatalf("GetDashboardAnalytics: %v", xerr)
}
@@ -156,3 +177,55 @@ func TestDashboardUsesTheSameSevenCalendarDaysForCardsAndTrend(t *testing.T) {
t.Fatalf("range starts %d whole days before today, want 6", days)
}
}
func TestDashboardWithoutFilterStaysWorkspaceWide(t *testing.T) {
repo := &dashboardAnalyticsRepoStub{}
svc := &analyticsService{analyticsRepo: repo}
got, xerr := svc.GetDashboardAnalytics(context.Background(), uuid.New(), "30d", models.DashboardFilter{})
if xerr != nil {
t.Fatalf("GetDashboardAnalytics: %v", xerr)
}
if repo.resolves != 0 || got.Scope != nil {
t.Fatalf("an empty filter resolved %d times and echoed %+v; want the workspace with no scope", repo.resolves, got.Scope)
}
for section, scope := range repo.scopes {
if scope != nil {
t.Errorf("%s narrowed to %+v without a filter", section, scope)
}
}
}
func TestDashboardFilterNarrowsEveryCampaignSectionToOneSet(t *testing.T) {
for _, tc := range []struct {
name string
ids []uuid.UUID
}{
{"campaigns", []uuid.UUID{uuid.New(), uuid.New()}},
// Foreign or deleted ids resolve to nothing, which must not read as no filter.
{"nothing in the workspace", []uuid.UUID{}},
} {
t.Run(tc.name, func(t *testing.T) {
resolved := &models.CampaignScope{CampaignIDs: tc.ids}
repo := &dashboardAnalyticsRepoStub{resolved: resolved}
svc := &analyticsService{analyticsRepo: repo}
filter := models.DashboardFilter{FolderIDs: []uuid.UUID{uuid.New()}}
got, xerr := svc.GetDashboardAnalytics(context.Background(), uuid.New(), "7d", filter)
if xerr != nil {
t.Fatalf("GetDashboardAnalytics: %v", xerr)
}
if repo.resolves != 1 {
t.Fatalf("resolved %d times, want once for every section", repo.resolves)
}
if got.Scope == nil || got.Scope.CampaignCount != len(tc.ids) {
t.Fatalf("scope = %+v, want the resolved %d campaigns echoed", got.Scope, len(tc.ids))
}
for _, section := range []string{"overall", "recent", "top", "trend"} {
if repo.scopes[section] != resolved {
t.Errorf("%s read %+v, want the resolved scope", section, repo.scopes[section])
}
}
})
}
}
@@ -72,6 +72,8 @@ func (s *JobsService) applyEmailSent(ctx context.Context, result models.SendEmai
}
}
switch task.TaskType {
case "email":
return s.fileSentForward(ctx, task.ID)
case "campaign":
s.repairCampaignSendStamp(ctx, task)
// Anchor the graduation ramp on the mailbox's first CONFIRMED cold
@@ -102,6 +104,22 @@ func (s *JobsService) applyEmailSent(ctx context.Context, result models.SendEmai
return nil
}
// fileSentForward files a forward's sent copy that Sent sync stored before its
// Message-ID was known; a copy stored after is filed by the insert itself.
func (s *JobsService) fileSentForward(ctx context.Context, taskID uuid.UUID) error {
if s.UniboxRepository == nil {
return nil
}
filed, err := s.UniboxRepository.FileSentForward(ctx, taskID)
if err != nil {
return err
}
for i := range filed {
s.publishEmailUpdated(ctx, filed[i].UserID, &filed[i].Message)
}
return nil
}
// repairCampaignSendStamp completes a reserved campaign step the control plane
// never stamped. A no-op in the normal case, where the stamp is already there.
func (s *JobsService) repairCampaignSendStamp(ctx context.Context, task *repository.Task) {
+36
View File
@@ -192,6 +192,42 @@ func TestSendEmailStoresTheForwardedMessage(t *testing.T) {
}
}
// A forward is filed into the forwarded message's conversation in Unibox but
// goes out as a new conversation: no provider thread, even when one is named.
func TestSendEmailFilesAForwardWithoutThreadingIt(t *testing.T) {
orgID := uuid.New()
tasks := &fakeSendTaskRepo{}
svc := &emailSendService{
taskRepo: tasks,
emailRepo: &fakeSendEmailRepo{account: &models.Email{OrganizationID: &orgID}},
}
fwd := forwardedFixture()
fwd.ThreadID = "conversation-1"
if _, xerr := svc.SendEmail(context.Background(), uuid.New(), orgID, uuid.New(), &SendEmailRequest{
To: []string{"team@example.com"}, Subject: "Fwd: Pricing details", ThreadID: "conversation-1", Forward: fwd,
}); xerr != nil {
t.Fatal(xerr)
}
et := tasks.stored
if et.ThreadID != nil {
t.Fatalf("forward carries thread %q, want none on the wire", *et.ThreadID)
}
if et.ForwardThreadID == nil || *et.ForwardThreadID != "conversation-1" {
t.Fatalf("forward filed into %v, want conversation-1", et.ForwardThreadID)
}
// A reply keeps its thread and is filed nowhere else.
if _, xerr := svc.SendEmail(context.Background(), uuid.New(), orgID, uuid.New(), &SendEmailRequest{
To: []string{"them@example.com"}, Subject: "Re: x", BodyPlain: "Thanks", ThreadID: "conversation-1",
}); xerr != nil {
t.Fatal(xerr)
}
if et := tasks.stored; et.ThreadID == nil || *et.ThreadID != "conversation-1" || et.ForwardThreadID != nil {
t.Fatalf("reply: thread %v forward thread %v", et.ThreadID, et.ForwardThreadID)
}
}
type fakeTicketRepo struct {
repository.TrackedLinkRepository
links map[uuid.UUID]string
+24 -17
View File
@@ -282,8 +282,14 @@ func (s *emailSendService) SendEmail(ctx context.Context, userID, orgID, account
ScheduledAt: &scheduledAt,
}
var threadID *string
if req.ThreadID != "" {
// A forward starts a new conversation on the wire and is only filed into the
// one it was forwarded from inside Unibox.
var threadID, forwardThreadID *string
if req.Forward != nil {
if req.Forward.ThreadID != "" {
forwardThreadID = &req.Forward.ThreadID
}
} else if req.ThreadID != "" {
threadID = &req.ThreadID
}
@@ -303,21 +309,22 @@ func (s *emailSendService) SendEmail(ctx context.Context, userID, orgID, account
bodyHTML, tracked := s.applyDirectTracking(ctx, account, taskID, bodyHTML, bodyHTML != "" || forwardedHTML != "")
emailTask := &repository.EmailTask{
TaskID: taskID,
To: req.To,
CC: req.CC,
BCC: req.BCC,
InReplyTo: req.InReplyTo,
Subject: req.Subject,
Body: bodyPlain,
BodyHTML: bodyHTML,
BodyPlain: bodyPlain,
ThreadID: threadID,
SendMode: sendMode,
Encrypted: false,
Tracked: tracked,
ForwardedHTML: forwardedHTML,
ForwardedPlain: forwardedPlain,
TaskID: taskID,
To: req.To,
CC: req.CC,
BCC: req.BCC,
InReplyTo: req.InReplyTo,
Subject: req.Subject,
Body: bodyPlain,
BodyHTML: bodyHTML,
BodyPlain: bodyPlain,
ThreadID: threadID,
SendMode: sendMode,
Encrypted: false,
Tracked: tracked,
ForwardedHTML: forwardedHTML,
ForwardedPlain: forwardedPlain,
ForwardThreadID: forwardThreadID,
}
if err := s.taskRepo.CreateEmailTaskFull(ctx, task, emailTask); err != nil {
+7 -6
View File
@@ -24,12 +24,13 @@ func (s *uniboxService) ForwardSource(ctx context.Context, orgID, id uuid.UUID)
}
out := &models.ForwardedMessage{
EmailID: msg.EmailID,
From: msg.FromAddr,
To: msg.ToAddr,
CC: msg.CC,
Subject: msg.Subject,
Date: msg.SentDate,
EmailID: msg.EmailID,
ThreadID: msg.ThreadID,
From: msg.FromAddr,
To: msg.ToAddr,
CC: msg.CC,
Subject: msg.Subject,
Date: msg.SentDate,
}
if out.Date.IsZero() {
out.Date = msg.InternalDate
@@ -0,0 +1,2 @@
ALTER TABLE unibox_emails DROP COLUMN IF EXISTS filed_forward;
ALTER TABLE email_tasks DROP COLUMN IF EXISTS forward_thread_id;
@@ -0,0 +1,8 @@
-- The Unibox conversation a forward was sent from. Only Warmbly files it there;
-- on the wire the forward stays a new conversation (no thread, no In-Reply-To).
ALTER TABLE email_tasks ADD COLUMN IF NOT EXISTS forward_thread_id text;
-- A sent forward filed into the conversation it was forwarded from. It is shown
-- there but is not part of that conversation for the recipient, so reply
-- threading skips it.
ALTER TABLE unibox_emails ADD COLUMN IF NOT EXISTS filed_forward boolean NOT NULL DEFAULT false;
+35 -1
View File
@@ -385,6 +385,39 @@ type DashboardAnalytics struct {
// the scheduler's clamps; the sidebar meter's denominator. Absent when
// it could not be computed.
CapacityToday *WorkspaceSendCapacity `json:"capacity_today,omitempty"`
// Scope is the campaign filter the campaign-derived sections were
// computed for; absent when they cover the whole workspace.
Scope *DashboardScope `json:"scope,omitempty"`
}
// DashboardFilter is the campaign filter a dashboard request asked for.
type DashboardFilter struct {
CampaignIDs []uuid.UUID
FolderIDs []uuid.UUID
}
// Empty reports a filter that leaves the dashboard workspace-wide.
func (f DashboardFilter) Empty() bool {
return len(f.CampaignIDs) == 0 && len(f.FolderIDs) == 0
}
// CampaignScope narrows campaign analytics to a resolved set of campaigns.
// A nil scope is the whole workspace; an empty set matches nothing.
type CampaignScope struct {
CampaignIDs []uuid.UUID
}
// DashboardScope echoes a resolved filter: the workspace's requested campaigns and folders, and the distinct campaigns they cover.
type DashboardScope struct {
Campaigns []DashboardScopeItem `json:"campaigns"`
Folders []DashboardScopeItem `json:"folders"`
CampaignCount int `json:"campaign_count"`
}
// DashboardScopeItem names one campaign or folder in a DashboardScope.
type DashboardScopeItem struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
// DashboardOverallStats contains aggregate statistics for the dashboard
@@ -404,7 +437,8 @@ type DashboardOverallStats struct {
ReplyRate float64 `json:"reply_rate"`
BounceRate float64 `json:"bounce_rate"`
ActiveCampaigns int `json:"active_campaigns"`
ActiveAccounts int `json:"active_accounts"`
// ActiveAccounts is workspace-wide; a campaign filter does not narrow it.
ActiveAccounts int `json:"active_accounts"`
}
// RecentActivityItem represents a single activity event
+8 -5
View File
@@ -56,11 +56,14 @@ type EmailMessage struct { // used for sending to the user
type ForwardedMessage struct {
// EmailID is the mailbox the message belongs to.
EmailID uuid.UUID
From []string
To []string
CC []string
Subject string
Date time.Time
// ThreadID is the Unibox conversation the message is in, where the forward
// is filed once sent.
ThreadID string
From []string
To []string
CC []string
Subject string
Date time.Time
// BodyHTML is the stored HTML, unsanitized; empty when the message has none.
BodyHTML string
// BodyPlain is the preview snippet when the full body is not stored.
@@ -0,0 +1,171 @@
package repository
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
// The dashboard's campaign filter resolves folders and campaigns to one
// deduplicated set inside the selected workspace, and every campaign section
// reads only that set (issue #869).
func TestLiveDashboardScopeNarrowsCampaignSections(t *testing.T) {
handle, pool := liveContactDB(t)
ctx := context.Background()
userID, orgID, otherOrgID := uuid.New(), uuid.New(), uuid.New()
inFolderA, inFolderB, outside, foreign := uuid.New(), uuid.New(), uuid.New(), uuid.New()
folderID, foreignFolderID := uuid.New(), uuid.New()
exec := func(query string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, query, args...); err != nil {
t.Fatalf("fixture: %v", err)
}
}
exec(`INSERT INTO users (id, first_name, last_name, email)
VALUES ($1, 'Dashboard', 'Scope', $2)`, userID, "dashboard-scope-"+uuid.NewString()+"@example.test")
exec(`INSERT INTO organizations (id, name, owner_user_id)
VALUES ($1, 'Dashboard scope', $3), ($2, 'Dashboard scope other', $3)`, orgID, otherOrgID, userID)
exec(`INSERT INTO folders (id, user_id, organization_id, title, color, position)
VALUES ($1, $3, $4, 'Client A', '#0ea5e9', 0), ($2, $3, $5, 'Other client', '#0ea5e9', 0)`,
folderID, foreignFolderID, userID, orgID, otherOrgID)
campaigns := []struct {
id uuid.UUID
org uuid.UUID
name string
status string
}{
{inFolderA, orgID, "Client A - Dentists", "active"},
{inFolderB, orgID, "Client A - Nurseries", "paused"},
{outside, orgID, "Client B", "active"},
{foreign, otherOrgID, "Foreign", "active"},
}
for _, c := range campaigns {
contactID, stepID := uuid.New(), uuid.New()
exec(`INSERT INTO campaigns (id, user_id, organization_id, name, description, days, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, '', 62, $5, NOW(), NOW())`, c.id, userID, c.org, c.name, c.status)
exec(`INSERT INTO contacts
(id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields, subscribed, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'Lead', 'Scope', '', '', '{}'::jsonb, true, NOW(), NOW())`,
contactID, userID, c.org, "dashboard-scope-lead-"+uuid.NewString()+"@example.test")
exec(`INSERT INTO campaign_leads (campaign_id, contact_id, position) VALUES ($1, $2, 0)`, c.id, contactID)
exec(`INSERT INTO sequences
(id, campaign_id, organization_id, name, subject, body_plain, body_html, wait_after, position, kind, created_at)
VALUES ($1, $2, $3, 'Step', '', '', '', 0, 1, 'email', NOW())`, stepID, c.id, c.org)
exec(`INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at, replied_at)
VALUES ($1, $2, $3, NOW() - interval '1 hour', NOW())`, c.id, contactID, stepID)
}
// The second campaign also sits in a folder of another workspace, which must not reach it.
exec(`INSERT INTO campaign_folders (campaign_id, folder_id)
VALUES ($1, $3), ($2, $3), ($2, $4)`, inFolderA, inFolderB, folderID, foreignFolderID)
t.Cleanup(func() {
for _, step := range []struct {
query string
arg any
}{
{`DELETE FROM campaigns WHERE organization_id = ANY($1)`, []uuid.UUID{orgID, otherOrgID}},
{`DELETE FROM folders WHERE organization_id = ANY($1)`, []uuid.UUID{orgID, otherOrgID}},
{`DELETE FROM contacts WHERE organization_id = ANY($1)`, []uuid.UUID{orgID, otherOrgID}},
{`DELETE FROM organizations WHERE id = ANY($1)`, []uuid.UUID{orgID, otherOrgID}},
{`DELETE FROM users WHERE id = $1`, userID},
} {
if _, err := pool.Exec(context.Background(), step.query, step.arg); err != nil {
t.Errorf("cleanup: %v", err)
}
}
})
repo := &analyticsRepository{DB: handle}
from, to := time.Now().Add(-24*time.Hour), time.Now().Add(24*time.Hour)
// The folder plus one of its own campaigns, a foreign campaign and a foreign folder.
view, scope, xerr := repo.ResolveDashboardScope(ctx, orgID, models.DashboardFilter{
CampaignIDs: []uuid.UUID{inFolderB, foreign},
FolderIDs: []uuid.UUID{folderID, foreignFolderID},
})
if xerr != nil {
t.Fatalf("ResolveDashboardScope: %v", xerr)
}
if view.CampaignCount != 2 || len(scope.CampaignIDs) != 2 {
t.Fatalf("resolved %d campaigns (%v), want the folder's two counted once", view.CampaignCount, scope.CampaignIDs)
}
if len(view.Campaigns) != 1 || view.Campaigns[0].ID != inFolderB || len(view.Folders) != 1 || view.Folders[0].Name != "Client A" {
t.Fatalf("echoed scope = %+v, want only this workspace's campaign and folder", view)
}
overall, xerr := repo.GetDashboardOverallStats(ctx, orgID, from, to, scope)
if xerr != nil {
t.Fatalf("GetDashboardOverallStats: %v", xerr)
}
if overall.TotalEmailsSent != 2 || overall.TotalReplies != 2 || overall.ActiveCampaigns != 1 {
t.Fatalf("scoped totals = sent %d, replies %d, active campaigns %d; want 2/2/1",
overall.TotalEmailsSent, overall.TotalReplies, overall.ActiveCampaigns)
}
trend, xerr := repo.GetDashboardDailyTrend(ctx, orgID, from, to, scope)
if xerr != nil {
t.Fatalf("GetDashboardDailyTrend: %v", xerr)
}
sent := 0
for _, d := range trend {
sent += d.Sent
}
if sent != 2 {
t.Fatalf("scoped trend sent = %d, want 2", sent)
}
top, xerr := repo.GetTopCampaigns(ctx, orgID, from, to, 10, "emails_sent", scope)
if xerr != nil {
t.Fatalf("GetTopCampaigns: %v", xerr)
}
recent, xerr := repo.GetRecentActivity(ctx, orgID, 20, scope)
if xerr != nil {
t.Fatalf("GetRecentActivity: %v", xerr)
}
inScope := map[uuid.UUID]bool{inFolderA: true, inFolderB: true}
if len(top) != 2 {
t.Fatalf("scoped top campaigns = %+v, want the two in scope", top)
}
for _, c := range top {
if !inScope[c.CampaignID] {
t.Errorf("top campaigns include %s outside the scope", c.Name)
}
}
if len(recent) == 0 {
t.Fatalf("scoped recent activity is empty, want the scoped replies")
}
for _, a := range recent {
if !inScope[a.CampaignID] {
t.Errorf("recent activity includes %s outside the scope", a.CampaignName)
}
}
// Nothing of this workspace's: an empty set, never the whole workspace.
view, scope, xerr = repo.ResolveDashboardScope(ctx, orgID, models.DashboardFilter{
CampaignIDs: []uuid.UUID{foreign},
FolderIDs: []uuid.UUID{foreignFolderID},
})
if xerr != nil {
t.Fatalf("ResolveDashboardScope: %v", xerr)
}
if view.CampaignCount != 0 || len(view.Campaigns) != 0 || len(view.Folders) != 0 {
t.Fatalf("foreign ids resolved to %+v, want nothing", view)
}
overall, xerr = repo.GetDashboardOverallStats(ctx, orgID, from, to, scope)
if xerr != nil {
t.Fatalf("GetDashboardOverallStats: %v", xerr)
}
if overall.TotalEmailsSent != 0 || overall.ActiveCampaigns != 0 {
t.Fatalf("an empty scope counted sent %d, active %d; want nothing", overall.TotalEmailsSent, overall.ActiveCampaigns)
}
whole, xerr := repo.GetDashboardOverallStats(ctx, orgID, from, to, nil)
if xerr != nil {
t.Fatalf("GetDashboardOverallStats: %v", xerr)
}
if whole.TotalEmailsSent != 3 {
t.Fatalf("workspace sent = %d, want this workspace's three", whole.TotalEmailsSent)
}
}
@@ -93,7 +93,7 @@ func TestLiveEngagementOriginReachesEverySurface(t *testing.T) {
}
analytics := NewAnalyticsRepository(handle)
recent, xerr := analytics.GetRecentActivity(ctx, f.org, 20)
recent, xerr := analytics.GetRecentActivity(ctx, f.org, 20, nil)
if xerr != nil {
t.Fatalf("recent activity: %v", xerr)
}
+105 -20
View File
@@ -46,10 +46,12 @@ type AnalyticsRepository interface {
GetContactCounts(ctx context.Context, orgID uuid.UUID) (*models.ContactsUsage, *errx.Error)
// Dashboard analytics
GetDashboardOverallStats(ctx context.Context, orgID uuid.UUID, from, to time.Time) (*models.DashboardOverallStats, *errx.Error)
GetRecentActivity(ctx context.Context, orgID uuid.UUID, limit int) ([]models.RecentActivityItem, *errx.Error)
GetTopCampaigns(ctx context.Context, orgID uuid.UUID, from, to time.Time, limit int, sortBy string) ([]models.TopCampaignStats, *errx.Error)
GetDashboardDailyTrend(ctx context.Context, orgID uuid.UUID, from, to time.Time) ([]models.DashboardDailyStats, *errx.Error)
// ResolveDashboardScope resolves a filter to this workspace's campaigns, deduplicated; foreign ids drop out.
ResolveDashboardScope(ctx context.Context, orgID uuid.UUID, filter models.DashboardFilter) (*models.DashboardScope, *models.CampaignScope, *errx.Error)
GetDashboardOverallStats(ctx context.Context, orgID uuid.UUID, from, to time.Time, scope *models.CampaignScope) (*models.DashboardOverallStats, *errx.Error)
GetRecentActivity(ctx context.Context, orgID uuid.UUID, limit int, scope *models.CampaignScope) ([]models.RecentActivityItem, *errx.Error)
GetTopCampaigns(ctx context.Context, orgID uuid.UUID, from, to time.Time, limit int, sortBy string, scope *models.CampaignScope) ([]models.TopCampaignStats, *errx.Error)
GetDashboardDailyTrend(ctx context.Context, orgID uuid.UUID, from, to time.Time, scope *models.CampaignScope) ([]models.DashboardDailyStats, *errx.Error)
GetAccountHealthSummary(ctx context.Context, orgID uuid.UUID) (*models.AccountHealthSummary, *errx.Error)
// Campaign hourly stats
@@ -172,6 +174,27 @@ func calendarDay(t time.Time) time.Time {
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}
// inCampaignScope narrows column to the scope's campaigns as $arg; a nil
// scope adds nothing, so the caller appends scopeArgs only when it is set.
func inCampaignScope(column string, scope *models.CampaignScope, arg int) string {
if scope == nil {
return ""
}
return fmt.Sprintf(" AND %s = ANY($%d::uuid[])", column, arg)
}
// scopeArgs is the parameter inCampaignScope refers to, or nothing.
func scopeArgs(scope *models.CampaignScope) []any {
if scope == nil {
return nil
}
ids := scope.CampaignIDs
if ids == nil {
ids = []uuid.UUID{}
}
return []any{ids}
}
func (r *analyticsRepository) GetCampaignSummary(ctx context.Context, orgID, campaignID uuid.UUID, period *models.DateRange) (*models.CampaignSummary, *errx.Error) {
cohort, cohortArgs := sentWithin("ccp", period, 3)
// Contacts and pending sends are the campaign's state now, so they read
@@ -643,7 +666,68 @@ func (r *analyticsRepository) GetContactCounts(ctx context.Context, orgID uuid.U
// Dashboard Analytics Methods
func (r *analyticsRepository) GetDashboardOverallStats(ctx context.Context, orgID uuid.UUID, from, to time.Time) (*models.DashboardOverallStats, *errx.Error) {
func (r *analyticsRepository) ResolveDashboardScope(ctx context.Context, orgID uuid.UUID, filter models.DashboardFilter) (*models.DashboardScope, *models.CampaignScope, *errx.Error) {
campaignIDs, folderIDs := filter.CampaignIDs, filter.FolderIDs
if campaignIDs == nil {
campaignIDs = []uuid.UUID{}
}
if folderIDs == nil {
folderIDs = []uuid.UUID{}
}
// A folder reaches only campaigns of its own workspace, and only through a folder of that workspace.
query := `
SELECT 'campaign', c.id, c.name, 0 FROM campaigns c
WHERE c.organization_id = $1 AND c.id = ANY($2::uuid[])
UNION ALL
SELECT 'folder', f.id, f.title, f.position FROM folders f
WHERE f.organization_id = $1 AND f.id = ANY($3::uuid[])
UNION ALL
SELECT 'member', c.id, '', 0 FROM campaigns c
WHERE c.organization_id = $1
AND (c.id = ANY($2::uuid[]) OR EXISTS (
SELECT 1 FROM campaign_folders cf
JOIN folders f ON f.id = cf.folder_id AND f.organization_id = $1
WHERE cf.campaign_id = c.id AND cf.folder_id = ANY($3::uuid[])
))
ORDER BY 1, 4, 3
`
params := []any{orgID, campaignIDs, folderIDs}
rows, err := r.DB.Query(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "query")
return nil, nil, errx.InternalError()
}
defer rows.Close()
scope := &models.DashboardScope{Campaigns: []models.DashboardScopeItem{}, Folders: []models.DashboardScopeItem{}}
members := &models.CampaignScope{CampaignIDs: []uuid.UUID{}}
for rows.Next() {
var kind string
var item models.DashboardScopeItem
var position int
if err := rows.Scan(&kind, &item.ID, &item.Name, &position); err != nil {
db.CaptureError(err, "", nil, "scan")
return nil, nil, errx.InternalError()
}
switch kind {
case "campaign":
scope.Campaigns = append(scope.Campaigns, item)
case "folder":
scope.Folders = append(scope.Folders, item)
default:
members.CampaignIDs = append(members.CampaignIDs, item.ID)
}
}
if err := rows.Err(); err != nil {
db.CaptureError(err, query, params, "rows")
return nil, nil, errx.InternalError()
}
scope.CampaignCount = len(members.CampaignIDs)
return scope, members, nil
}
func (r *analyticsRepository) GetDashboardOverallStats(ctx context.Context, orgID uuid.UUID, from, to time.Time, scope *models.CampaignScope) (*models.DashboardOverallStats, *errx.Error) {
query := `
SELECT
COUNT(CASE WHEN ccp.sent_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as total_sent,
@@ -659,15 +743,15 @@ func (r *analyticsRepository) GetDashboardOverallStats(ctx context.Context, orgI
) THEN 1 END) as machine_clicks,
COUNT(CASE WHEN ccp.replied_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as total_replies,
COUNT(CASE WHEN ccp.bounced_at IS NOT NULL AND ccp.sent_at >= $2 AND ccp.sent_at <= $3 THEN 1 END) as total_bounces,
(SELECT COUNT(*) FROM campaigns WHERE organization_id = $1 AND status = 'active') as active_campaigns,
(SELECT COUNT(*) FROM campaigns ac WHERE ac.organization_id = $1 AND ac.status = 'active'` + inCampaignScope("ac.id", scope, 4) + `) as active_campaigns,
(SELECT COUNT(*) FROM email_accounts WHERE organization_id = $1 AND status = 'active') as active_accounts
FROM campaign_contact_progress ccp
JOIN campaigns c ON c.id = ccp.campaign_id
JOIN sequences s ON s.id = ccp.sequence_id AND s.kind = 'email'
WHERE c.organization_id = $1
WHERE c.organization_id = $1` + inCampaignScope("c.id", scope, 4) + `
`
params := []any{orgID, from, to}
params := append([]any{orgID, from, to}, scopeArgs(scope)...)
var stats models.DashboardOverallStats
err := r.DB.QueryRow(ctx, query, params...).Scan(
@@ -697,7 +781,8 @@ func (r *analyticsRepository) GetDashboardOverallStats(ctx context.Context, orgI
return &stats, nil
}
func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid.UUID, limit int) ([]models.RecentActivityItem, *errx.Error) {
func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid.UUID, limit int, scope *models.CampaignScope) ([]models.RecentActivityItem, *errx.Error) {
inScope := inCampaignScope("ccp.campaign_id", scope, 3)
// Union query to get recent opens, clicks, replies, and bounces. The
// origin of an open or click is looked up only for the rows that make
// the page, from the person's first logged open or click on the step,
@@ -711,7 +796,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid.
FROM campaign_contact_progress ccp
JOIN campaigns c ON c.id = ccp.campaign_id
JOIN contacts co ON co.id = ccp.contact_id
WHERE c.organization_id = $1 AND ccp.opened_at IS NOT NULL AND NOT ccp.opened_machine
WHERE c.organization_id = $1 AND ccp.opened_at IS NOT NULL AND NOT ccp.opened_machine` + inScope + `
UNION ALL
@@ -725,7 +810,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid.
FROM campaign_contact_progress ccp
JOIN campaigns c ON c.id = ccp.campaign_id
JOIN contacts co ON co.id = ccp.contact_id
WHERE c.organization_id = $1 AND ccp.clicked_at IS NOT NULL
WHERE c.organization_id = $1 AND ccp.clicked_at IS NOT NULL` + inScope + `
UNION ALL
@@ -735,7 +820,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid.
FROM campaign_contact_progress ccp
JOIN campaigns c ON c.id = ccp.campaign_id
JOIN contacts co ON co.id = ccp.contact_id
WHERE c.organization_id = $1 AND ccp.replied_at IS NOT NULL
WHERE c.organization_id = $1 AND ccp.replied_at IS NOT NULL` + inScope + `
UNION ALL
@@ -745,7 +830,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid.
FROM campaign_contact_progress ccp
JOIN campaigns c ON c.id = ccp.campaign_id
JOIN contacts co ON co.id = ccp.contact_id
WHERE c.organization_id = $1 AND ccp.bounced_at IS NOT NULL
WHERE c.organization_id = $1 AND ccp.bounced_at IS NOT NULL` + inScope + `
), page AS (
SELECT * FROM recent_events
ORDER BY timestamp DESC
@@ -780,7 +865,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid.
ORDER BY p.timestamp DESC
`
params := []any{orgID, limit}
params := append([]any{orgID, limit}, scopeArgs(scope)...)
rows, err := r.DB.Query(ctx, query, params...)
if err != nil {
@@ -812,7 +897,7 @@ func (r *analyticsRepository) GetRecentActivity(ctx context.Context, orgID uuid.
return activities, nil
}
func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, orgID uuid.UUID, from, to time.Time, limit int, sortBy string) ([]models.TopCampaignStats, *errx.Error) {
func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, orgID uuid.UUID, from, to time.Time, limit int, sortBy string, scope *models.CampaignScope) ([]models.TopCampaignStats, *errx.Error) {
// Default sort by emails_sent
orderClause := "emails_sent DESC"
switch sortBy {
@@ -843,14 +928,14 @@ func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, orgID uuid.UU
LEFT JOIN campaign_contact_progress ccp ON ccp.campaign_id = c.id
AND ccp.sent_at >= $2 AND ccp.sent_at <= $3
AND EXISTS (SELECT 1 FROM sequences s WHERE s.id = ccp.sequence_id AND s.kind = 'email')
WHERE c.organization_id = $1
WHERE c.organization_id = $1` + inCampaignScope("c.id", scope, 5) + `
GROUP BY c.id, c.name, c.status
HAVING COUNT(CASE WHEN ccp.sent_at IS NOT NULL THEN 1 END) > 0
ORDER BY ` + orderClause + `
LIMIT $4
`
params := []any{orgID, from, to, limit}
params := append([]any{orgID, from, to, limit}, scopeArgs(scope)...)
rows, err := r.DB.Query(ctx, query, params...)
if err != nil {
@@ -872,7 +957,7 @@ func (r *analyticsRepository) GetTopCampaigns(ctx context.Context, orgID uuid.UU
return campaigns, nil
}
func (r *analyticsRepository) GetDashboardDailyTrend(ctx context.Context, orgID uuid.UUID, from, to time.Time) ([]models.DashboardDailyStats, *errx.Error) {
func (r *analyticsRepository) GetDashboardDailyTrend(ctx context.Context, orgID uuid.UUID, from, to time.Time, scope *models.CampaignScope) ([]models.DashboardDailyStats, *errx.Error) {
query := `
SELECT
sent_at::date::text as date,
@@ -886,12 +971,12 @@ func (r *analyticsRepository) GetDashboardDailyTrend(ctx context.Context, orgID
WHERE c.organization_id = $1
AND ccp.sent_at IS NOT NULL
AND ccp.sent_at::date >= $2
AND ccp.sent_at::date <= $3
AND ccp.sent_at::date <= $3` + inCampaignScope("c.id", scope, 4) + `
GROUP BY sent_at::date
ORDER BY sent_at::date ASC
`
params := []any{orgID, from, to}
params := append([]any{orgID, from, to}, scopeArgs(scope)...)
rows, err := r.DB.Query(ctx, query, params...)
if err != nil {
+15 -9
View File
@@ -64,6 +64,9 @@ type EmailTask struct {
// appended after the body and signature when the send goes out.
ForwardedHTML string
ForwardedPlain string
// ForwardThreadID is the Unibox conversation a forward was sent from. Its
// sent copy is filed there; ThreadID stays nil so it goes out as no reply.
ForwardThreadID *string
}
// TaskFailure represents a task failure record
@@ -280,8 +283,8 @@ func (r *taskRepository) CreateWarmupTask(ctx context.Context, warmupTask *Warmu
// CreateEmailTask creates email-specific task data
func (r *taskRepository) CreateEmailTask(ctx context.Context, emailTask *EmailTask) error {
query := `
INSERT INTO email_tasks (task_id, to_addrs, cc, bcc, in_reply_to, subject, body, body_html, body_plain, thread_id, send_mode, encrypted, tracked, forwarded_html, forwarded_plain)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
INSERT INTO email_tasks (task_id, to_addrs, cc, bcc, in_reply_to, subject, body, body_html, body_plain, thread_id, send_mode, encrypted, tracked, forwarded_html, forwarded_plain, forward_thread_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
`
sendMode := emailTask.SendMode
@@ -305,6 +308,7 @@ func (r *taskRepository) CreateEmailTask(ctx context.Context, emailTask *EmailTa
emailTask.Tracked,
emailTask.ForwardedHTML,
emailTask.ForwardedPlain,
emailTask.ForwardThreadID,
)
return err
@@ -651,8 +655,8 @@ func (r *taskRepository) CreateEmailTaskFull(ctx context.Context, task *Task, em
}
etQuery := `
INSERT INTO email_tasks (task_id, to_addrs, cc, bcc, in_reply_to, subject, body, body_html, body_plain, thread_id, send_mode, encrypted, tracked, forwarded_html, forwarded_plain)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
INSERT INTO email_tasks (task_id, to_addrs, cc, bcc, in_reply_to, subject, body, body_html, body_plain, thread_id, send_mode, encrypted, tracked, forwarded_html, forwarded_plain, forward_thread_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
`
_, err = tx.Exec(ctx, etQuery,
emailTask.TaskID,
@@ -670,6 +674,7 @@ func (r *taskRepository) CreateEmailTaskFull(ctx context.Context, task *Task, em
emailTask.Tracked,
emailTask.ForwardedHTML,
emailTask.ForwardedPlain,
emailTask.ForwardThreadID,
)
if err != nil {
return err
@@ -1193,7 +1198,7 @@ func (r *taskRepository) ListScheduledInOrg(ctx context.Context, orgID uuid.UUID
-- A forward with no note previews the message it forwards.
CASE WHEN btrim(et.body_plain) = '' THEN et.forwarded_plain ELSE et.body_plain END,
et.body_html,
et.thread_id
COALESCE(et.thread_id, et.forward_thread_id)
FROM tasks t
INNER JOIN email_tasks et ON et.task_id = t.id
INNER JOIN email_accounts ea ON ea.id = t.email_account_id
@@ -1265,14 +1270,14 @@ func (r *taskRepository) ListScheduledInOrgByThread(ctx context.Context, orgID u
-- A forward with no note previews the message it forwards.
CASE WHEN btrim(et.body_plain) = '' THEN et.forwarded_plain ELSE et.body_plain END,
et.body_html,
et.thread_id
COALESCE(et.thread_id, et.forward_thread_id)
FROM tasks t
INNER JOIN email_tasks et ON et.task_id = t.id
INNER JOIN email_accounts ea ON ea.id = t.email_account_id
WHERE ea.organization_id = $1
AND t.task_type = 'email'
AND t.status = 'pending'
AND et.thread_id = $2
AND (et.thread_id = $2 OR et.forward_thread_id = $2)
AND (COALESCE(cardinality($4::uuid[]), 0) = 0 OR ea.id = ANY($4::uuid[]))
ORDER BY t.scheduled_at ASC NULLS LAST, t.created_at ASC
LIMIT $3
@@ -1364,7 +1369,8 @@ func (r *taskRepository) CancelScheduledInOrg(ctx context.Context, taskID, orgID
// ProviderThreadForMailbox returns the provider thread the mailbox holds for a
// unibox conversation: the conversation's own id when the mailbox has a message
// in it (synced, or sent and not yet synced back), else the thread its latest
// earlier reply into the conversation landed in, else "".
// earlier reply into the conversation landed in, else "". A forward filed into
// the conversation is not in its provider thread, so it does not count.
func (r *taskRepository) ProviderThreadForMailbox(ctx context.Context, emailAccountID uuid.UUID, threadID string) (string, error) {
threadID = strings.TrimSpace(threadID)
if threadID == "" {
@@ -1373,7 +1379,7 @@ func (r *taskRepository) ProviderThreadForMailbox(ctx context.Context, emailAcco
var handle string
err := r.db.QueryRow(ctx, `
SELECT CASE
WHEN EXISTS (SELECT 1 FROM unibox_emails WHERE email_id = $1 AND thread_id = $2)
WHEN EXISTS (SELECT 1 FROM unibox_emails WHERE email_id = $1 AND thread_id = $2 AND NOT filed_forward)
OR EXISTS (SELECT 1 FROM tasks WHERE email_account_id = $1 AND status = 'completed' AND thread_id = $2)
THEN $2
ELSE COALESCE((
+82 -6
View File
@@ -132,6 +132,10 @@ type UniboxRepository interface {
// a reply that arrives with only a provider thread id can still carry the
// In-Reply-To header the recipient's mail client threads on.
LatestMessageIDInThread(ctx context.Context, orgID uuid.UUID, threadID string) (string, error)
// FileSentForward files the sending mailbox's stored copy of a Unibox
// forward into the conversation it was forwarded from, for a copy synced
// before the send's Message-ID was known. Returns the rows it moved.
FileSentForward(ctx context.Context, taskID uuid.UUID) ([]FiledSentForward, error)
// ListMissingBodyText pages through messages stored before bodies were
// indexed for search, oldest id first from afterID. SetBodyText writes the
@@ -230,17 +234,21 @@ func ownAddressSQL(arrayExpr, orgArg string) string {
)`, arrayExpr, bareAddrSQL("own.addr"), orgArg)
}
// CreateEntry stores a synced message. A mailbox's copy of a forward it sent
// from Unibox is filed into the conversation it was forwarded from, and e
// carries the thread it was stored under.
func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e *models.EmailMessageStoreData) error {
query := `
WITH fwd AS (` + sentForwardThreadSQL + `)
INSERT INTO unibox_emails (
id, user_id, email_id, mailbox, folder_path, thread_id, message_id,
gmail_id, parent_id, uid, mod_seq,
flags, bcc, cc, from_addr, in_reply_to, reply_to,
to_addr, subject, size, internal_date, sent_date,
snippet, seen, created_at, updated_at, body_text, folder,
provider_folder, automated
provider_folder, automated, filed_forward
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$1, $2, $3, $4, $5, COALESCE((SELECT thread_id FROM fwd), $6), $7,
$8, $9, $10, $11,
$12, $13, $14, $15, $16, $17,
$18, $19, $20, $21, $22,
@@ -252,14 +260,17 @@ func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e
WHERE r.organization_id = (SELECT organization_id FROM email_accounts WHERE id = $3)
AND r.message_id = $7 AND $7 <> ''
AND r.automated
)
),
EXISTS (SELECT 1 FROM fwd)
)
ON CONFLICT (id) DO NOTHING
RETURNING thread_id
`
// The array columns are NOT NULL. A nil Go slice binds as SQL NULL, so a
// message with no In-Reply-To (any thread root) would fail the insert.
_, err := r.db.Exec(ctx, query,
var threadID string
err := r.db.QueryRow(ctx, query,
e.ID, userID, e.EmailID, e.Mailbox, e.FolderPath, e.ThreadID, e.MessageID,
e.GmailID, e.ParentID, e.UID, e.ModSeq,
textArray(e.Flags), textArray(e.BCC), textArray(e.CC), textArray(e.FromAddr),
@@ -269,10 +280,73 @@ func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e
// $28 is both columns: a message starts out where the provider put it,
// and only diverges once someone files it in Warmbly.
models.NormalizeFolder(e.Folder, e.Flags),
)
return err
).Scan(&threadID)
if errors.Is(err, pgx.ErrNoRows) {
// Already stored: the conflict leaves the row as it was.
return nil
}
if err != nil {
return err
}
e.ThreadID = threadID
return nil
}
// FiledSentForward is a stored copy FileSentForward moved, with its mailbox owner.
type FiledSentForward struct {
UserID uuid.UUID
Message models.EmailMessageStoreData
}
func (r *uniboxRepository) FileSentForward(ctx context.Context, taskID uuid.UUID) ([]FiledSentForward, error) {
// Only the Sent copy can have raced the result, and (email_id, folder) is indexed.
rows, err := r.db.Query(ctx, `
UPDATE unibox_emails ue
SET thread_id = et.forward_thread_id,
filed_forward = true,
updated_at = NOW()
FROM tasks t
JOIN email_tasks et ON et.task_id = t.id
WHERE t.id = $1
AND t.task_type = 'email'
AND t.message_id <> ''
AND COALESCE(et.forward_thread_id, '') <> ''
AND ue.email_id = t.email_account_id
AND ue.folder = $2
AND ue.message_id IN (t.message_id, btrim(t.message_id, '<> '), '<' || btrim(t.message_id, '<> ') || '>')
AND NOT (ue.filed_forward AND ue.thread_id = et.forward_thread_id)
RETURNING ue.id, ue.user_id, ue.email_id, ue.thread_id, ue.subject, ue.from_addr, ue.snippet, ue.folder, ue.flags
`, taskID, models.FolderSent)
if err != nil {
return nil, err
}
defer rows.Close()
var out []FiledSentForward
for rows.Next() {
var f FiledSentForward
m := &f.Message
if err := rows.Scan(&m.ID, &f.UserID, &m.EmailID, &m.ThreadID, &m.Subject, &m.FromAddr, &m.Snippet, &m.Folder, &m.Flags); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// sentForwardThreadSQL finds the conversation a mailbox's message ($3, $7) was
// forwarded from, when it is the mailbox's own copy of a Unibox forward.
const sentForwardThreadSQL = `
SELECT et.forward_thread_id AS thread_id
FROM tasks t
JOIN email_tasks et ON et.task_id = t.id
WHERE $7 <> ''
AND t.email_account_id = $3
AND t.task_type = 'email'
AND t.message_id IN ($7, btrim($7, '<> '), '<' || btrim($7, '<> ') || '>')
AND COALESCE(et.forward_thread_id, '') <> ''
ORDER BY t.created_at DESC
LIMIT 1`
// textArray coalesces a nil slice to an empty one so it binds as '{}' rather
// than NULL.
func textArray(v []string) []string {
@@ -1459,6 +1533,8 @@ func (r *uniboxRepository) LatestMessageIDInThread(ctx context.Context, orgID uu
FROM unibox_emails
WHERE thread_id = $2
AND message_id <> ''
-- A filed forward never reached the people in the conversation.
AND NOT filed_forward
AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)
ORDER BY internal_date DESC
LIMIT 1
+1 -1
View File
@@ -125,7 +125,7 @@ func TestLiveCopiedReplyAtAnUnnamedMailboxFindsNothing(t *testing.T) {
func TestLiveRecentActivityNamesTheSendingMailbox(t *testing.T) {
handle, _ := liveContactDB(t)
f := newReplyInboxFixture(t, func(inbox string) string { return inbox })
items, xerr := NewAnalyticsRepository(handle).GetRecentActivity(context.Background(), f.org, 10)
items, xerr := NewAnalyticsRepository(handle).GetRecentActivity(context.Background(), f.org, 10, nil)
if xerr != nil {
t.Fatalf("GetRecentActivity: %v", xerr)
}
@@ -0,0 +1,136 @@
package repository
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
// A forward sent from a Unibox conversation is filed into it, from either
// mailbox and whichever of the Sent sync and the send result lands first, while
// reply threading never treats it as part of the conversation.
//
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
// go test ./internal/repository/ -run LiveUniboxForward -v
func TestLiveUniboxForwardIsFiledIntoItsConversation(t *testing.T) {
handle, pool := liveContactDB(t)
requireSchemaVersion(t, pool, 275)
f := newRoutedPairsFixture(t, pool, 0)
ctx := context.Background()
unibox := NewUniboxRepository(handle)
tasks := NewTaskRepository(pool)
other := uuid.New()
if _, err := pool.Exec(ctx, `INSERT INTO email_accounts (id, user_id, organization_id, email, name,
signature_plain, signature_html, provider, status, campaign_limit, min_wait_time, timezone)
VALUES ($1, $2, $3, $4, 'Other', '', '', 'smtp_imap', 'active', 50, 0, 'UTC')`,
other, f.owner, f.org, "fwd-other-"+other.String()[:8]+"@test.local"); err != nil {
t.Fatalf("second mailbox: %v", err)
}
var taskIDs []uuid.UUID
t.Cleanup(func() {
c := context.Background()
for _, step := range []struct {
sql string
arg any
}{
{`DELETE FROM unibox_emails WHERE email_id = ANY($1)`, []uuid.UUID{f.mailbox, other}},
{`DELETE FROM email_tasks WHERE task_id = ANY($1)`, taskIDs},
{`DELETE FROM tasks WHERE id = ANY($1)`, taskIDs},
{`DELETE FROM email_accounts WHERE id = $1`, other},
} {
if _, err := pool.Exec(c, step.sql, step.arg); err != nil {
t.Errorf("cleanup %q: %v", step.sql, err)
}
}
})
const conversation = "conv-forward-1"
store := func(mailbox uuid.UUID, threadID, messageID, folder string, at time.Time) *models.EmailMessageStoreData {
t.Helper()
m := &models.EmailMessageStoreData{
ID: uuid.New(), EmailID: mailbox, ThreadID: threadID, MessageID: messageID, Folder: folder,
Subject: "Packaging", InternalDate: at, SentDate: at, CreatedAt: at, UpdatedAt: at,
}
if err := unibox.CreateEntry(ctx, f.owner, m); err != nil {
t.Fatalf("store %s: %v", messageID, err)
}
return m
}
forward := func(mailbox uuid.UUID, status, messageID string) uuid.UUID {
t.Helper()
id := uuid.New()
taskIDs = append(taskIDs, id)
at := time.Now().Add(time.Hour)
thread := conversation
if err := tasks.CreateEmailTaskFull(ctx,
&Task{ID: id, TaskType: "email", EmailAccountID: mailbox, Status: status, MessageID: messageID, ScheduledAt: &at},
&EmailTask{TaskID: id, To: []string{"team@example.com"}, Subject: "Fwd: Packaging", ForwardedPlain: "forwarded", ForwardThreadID: &thread},
); err != nil {
t.Fatalf("forward task: %v", err)
}
return id
}
base := time.Now().Add(-time.Hour)
store(f.mailbox, conversation, "<prospect@example.com>", models.FolderInbox, base)
// The send result came first: the Sent copy is filed as it is stored, from
// another mailbox and with the Message-ID written without brackets.
forward(other, "completed", "<fwd-1@test.local>")
sent := store(other, "other-own-thread", "fwd-1@test.local", models.FolderSent, base.Add(time.Minute))
if sent.ThreadID != conversation {
t.Fatalf("sent copy stored under %q, want %q", sent.ThreadID, conversation)
}
// The Sent copy came first: the send result files it.
late := forward(f.mailbox, "completed", "")
early := store(f.mailbox, "own-thread-2", "<fwd-2@test.local>", models.FolderSent, base.Add(2*time.Minute))
if early.ThreadID != "own-thread-2" {
t.Fatalf("copy with no known send filed into %q", early.ThreadID)
}
if err := tasks.UpdateTaskMessageID(ctx, late, "<fwd-2@test.local>"); err != nil {
t.Fatal(err)
}
filed, err := unibox.FileSentForward(ctx, late)
if err != nil {
t.Fatal(err)
}
if len(filed) != 1 || filed[0].Message.ID != early.ID || filed[0].Message.ThreadID != conversation || filed[0].UserID != f.owner {
t.Fatalf("filed %+v", filed)
}
if again, err := unibox.FileSentForward(ctx, late); err != nil || len(again) != 0 {
t.Fatalf("second filing moved %d rows (err %v)", len(again), err)
}
thread, err := unibox.GetByThread(ctx, f.org, uuid.Nil, conversation, 10, "")
if err != nil {
t.Fatal(err)
}
if len(thread.Data) != 3 {
t.Fatalf("conversation holds %d messages, want the reply and both forwards", len(thread.Data))
}
// A reply still answers the prospect, and a mailbox whose only message in the
// conversation is a forward has no provider thread in it.
parent, err := unibox.LatestMessageIDInThread(ctx, f.org, conversation)
if err != nil || parent != "<prospect@example.com>" {
t.Fatalf("reply parent %q (err %v), want the prospect's message", parent, err)
}
if own, err := tasks.ProviderThreadForMailbox(ctx, other, conversation); err != nil || own != "" {
t.Fatalf("provider thread %q (err %v), want none", own, err)
}
// A queued forward is listed with the conversation's scheduled sends.
queued := forward(other, "pending", "")
items, err := tasks.ListScheduledInOrgByThread(ctx, f.org, conversation, nil, 10)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].TaskID != queued || items[0].ThreadID == nil || *items[0].ThreadID != conversation {
t.Fatalf("scheduled in conversation: %+v", items)
}
}
@@ -93,21 +93,21 @@ func TestLiveUsageOverviewCountsTheSelectedOrganization(t *testing.T) {
}
from, to := time.Now().Add(-24*time.Hour), time.Now().Add(24*time.Hour)
overall, xerr := repo.GetDashboardOverallStats(ctx, orgID, from, to)
overall, xerr := repo.GetDashboardOverallStats(ctx, orgID, from, to, nil)
if xerr != nil {
t.Fatalf("GetDashboardOverallStats: %v", xerr)
}
if overall.TotalEmailsSent != 1 {
t.Fatalf("dashboard total sent = %d, want the one email step", overall.TotalEmailsSent)
}
daily, xerr := repo.GetDashboardDailyTrend(ctx, orgID, from, to)
daily, xerr := repo.GetDashboardDailyTrend(ctx, orgID, from, to, nil)
if xerr != nil {
t.Fatalf("GetDashboardDailyTrend: %v", xerr)
}
if len(daily) != 1 || daily[0].Sent != 1 {
t.Fatalf("dashboard daily trend = %+v, want one email sent", daily)
}
top, xerr := repo.GetTopCampaigns(ctx, orgID, from, to, 10, "emails_sent")
top, xerr := repo.GetTopCampaigns(ctx, orgID, from, to, 10, "emails_sent", nil)
if xerr != nil {
t.Fatalf("GetTopCampaigns: %v", xerr)
}
@@ -0,0 +1,202 @@
import SwiftUI
/// Narrows the analytics overview to campaign folders and campaigns, the
/// same filter as the web Analytics page: the server unions every pick and
/// counts each campaign once, and an empty pick is the whole workspace.
/// Folders come from the session; campaigns page in from `GET campaigns`.
struct AnalyticsCampaignFilterSheet: View {
@Environment(AppEnvironment.self) private var env
@Environment(\.dismiss) private var dismiss
var initial: AnalyticsCampaignFilter
var onApply: (AnalyticsCampaignFilter) -> Void
@State private var query = ""
@State private var picked = AnalyticsCampaignFilter()
@State private var seeded = false
@State private var campaigns: [Campaign] = []
@State private var nextCursor: String?
@State private var hasMore = false
@State private var isLoading = false
@State private var errorMessage: String?
@State private var togglePulse = 0
private var trimmedQuery: String { query.trimmingCharacters(in: .whitespaces) }
/// The session's campaign folders, in position order, filtered by the search.
private var folders: [UserGroup] {
let all = (env.session.user?.folders ?? []).sorted { ($0.position ?? 0) < ($1.position ?? 0) }
let q = trimmedQuery.lowercased()
guard !q.isEmpty else { return all }
return all.filter { ($0.name ?? "").lowercased().contains(q) }
}
var body: some View {
NavigationStack {
list
.navigationTitle("Campaigns")
.navigationBarTitleDisplayMode(.inline)
.searchable(
text: $query,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search campaigns and folders"
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Apply") {
onApply(picked)
dismiss()
}
.fontWeight(.semibold)
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.sensoryFeedback(.selection, trigger: togglePulse)
.task {
guard !seeded else { return }
seeded = true
picked = initial
}
.task(id: trimmedQuery) {
// Hold the search until typing pauses.
if !trimmedQuery.isEmpty { try? await Task.sleep(for: .milliseconds(300)) }
guard !Task.isCancelled else { return }
await load(reset: true)
}
}
private var list: some View {
List {
Button {
togglePulse += 1
withAnimation(.snappy) { picked = AnalyticsCampaignFilter() }
} label: {
HStack {
Text("All campaigns")
.font(.body.weight(.medium))
.foregroundStyle(.primary)
Spacer()
if picked.isEmpty {
Image(systemName: "checkmark")
.font(.body.weight(.semibold))
.foregroundStyle(WTheme.accent)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if !folders.isEmpty {
Section("Folders") {
ForEach(folders) { folder in
row(
title: folder.name ?? "Folder",
dot: Color(uniboxHex: folder.color) ?? WTheme.accent,
isPicked: picked.folders.contains(folder.id)
) {
toggle(\.folders, folder.id)
}
}
}
}
Section("Campaigns") {
ForEach(campaigns) { campaign in
row(
title: campaign.name,
dot: statusColor(campaign.status),
isPicked: picked.campaigns.contains(campaign.id)
) {
toggle(\.campaigns, campaign.id)
}
.onAppear {
if campaign.id == campaigns.last?.id { Task { await load(reset: false) } }
}
}
if isLoading {
ProgressView().frame(maxWidth: .infinity)
} else if let errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(WTheme.negative)
} else if campaigns.isEmpty {
Text("No campaigns found.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.listStyle(.plain)
.scrollDismissesKeyboard(.immediately)
}
private func row(title: String, dot: Color, isPicked: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 12) {
Circle()
.fill(dot)
.frame(width: 9, height: 9)
Text(title)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
.lineLimit(1)
Spacer(minLength: 8)
Image(systemName: isPicked ? "checkmark.circle.fill" : "circle")
.font(.system(size: 21))
.foregroundStyle(isPicked ? WTheme.accent : Color(.systemGray4))
}
.padding(.vertical, 3)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private func toggle(_ key: WritableKeyPath<AnalyticsCampaignFilter, Set<String>>, _ id: String) {
togglePulse += 1
withAnimation(.snappy) {
if picked[keyPath: key].contains(id) {
picked[keyPath: key].remove(id)
} else {
picked[keyPath: key].insert(id)
}
}
}
private func statusColor(_ status: String?) -> Color {
switch status {
case "active": WTheme.positive
case "paused": Tone.amber.color
default: Color(.systemGray3)
}
}
private func load(reset: Bool) async {
if !reset, !hasMore || isLoading { return }
let search = trimmedQuery
isLoading = true
defer { isLoading = false }
var params: [String: String?] = ["limit": "50"]
if !search.isEmpty { params["q"] = search }
if !reset, let nextCursor { params["cursor"] = nextCursor }
do {
let page: CampaignListPage = try await env.api.get("campaigns", query: params)
guard search == trimmedQuery else { return }
let fresh = page.data ?? []
if reset {
campaigns = fresh
} else {
campaigns.append(contentsOf: fresh.filter { new in !campaigns.contains { $0.id == new.id } })
}
nextCursor = page.pagination?.nextCursor
hasMore = page.pagination?.hasMore ?? false
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}
@@ -69,9 +69,12 @@ struct DashboardAnalytics: Codable, Sendable {
var topCampaigns: [AnalyticsTopCampaign]?
var accountHealth: AnalyticsAccountHealthCounts?
var dailyTrend: [AnalyticsTrendPoint]?
/// The campaign filter the campaign sections were computed for; nil
/// when they cover the whole workspace.
var scope: AnalyticsDashboardScope?
enum CodingKeys: String, CodingKey {
case period
case period, scope
case overallStats = "overall_stats"
case recentActivity = "recent_activity"
case topCampaigns = "top_campaigns"
@@ -80,6 +83,33 @@ struct DashboardAnalytics: Codable, Sendable {
}
}
/// Campaigns and folders the overview is narrowed to; the server unions
/// them and counts each campaign once. Empty means every campaign.
struct AnalyticsCampaignFilter: Equatable, Sendable {
var campaigns: Set<String> = []
var folders: Set<String> = []
var isEmpty: Bool { campaigns.isEmpty && folders.isEmpty }
}
/// The requested campaigns and folders that belong to the workspace, and
/// how many distinct campaigns they cover together.
struct AnalyticsDashboardScope: Codable, Sendable {
var campaigns: [AnalyticsScopeItem]?
var folders: [AnalyticsScopeItem]?
var campaignCount: Int?
enum CodingKeys: String, CodingKey {
case campaigns, folders
case campaignCount = "campaign_count"
}
}
struct AnalyticsScopeItem: Codable, Sendable {
var id: String
var name: String?
}
struct AnalyticsOverallStats: Codable, Sendable {
var totalEmailsSent: Int?
var totalOpens: Int?
@@ -95,6 +95,7 @@ struct AnalyticsRootView: View {
@State private var metric: AnalyticsTrendMetric = .sent
@State private var sidebarOpen = false
@State private var sidebarDrag: CGFloat = 0
@State private var filterOpen = false
private static let sidebarWidth: CGFloat = 300
@@ -162,6 +163,14 @@ struct AnalyticsRootView: View {
.onChange(of: store.period) {
Task { await store.load(env.api) }
}
.onChange(of: store.campaignFilter) {
Task { await store.reloadDashboard(env.api) }
}
.sheet(isPresented: $filterOpen) {
AnalyticsCampaignFilterSheet(initial: store.campaignFilter) { picked in
store.campaignFilter = picked
}
}
.sensoryFeedback(.selection, trigger: scope)
.sensoryFeedback(.selection, trigger: store.period)
.sensoryFeedback(.selection, trigger: metric)
@@ -196,6 +205,7 @@ struct AnalyticsRootView: View {
VStack(alignment: .leading, spacing: 14) {
topRow
periodPicker
campaignFilterChip
heroStats
}
.padding(.horizontal, 16)
@@ -268,6 +278,57 @@ struct AnalyticsRootView: View {
.padding(.horizontal, 4)
}
private var scopeLabel: String {
store.scopeLabel { id in env.session.user?.folders?.first { $0.id == id }?.name }
}
/// Opens the campaign filter; the overview's campaign numbers follow it.
private var campaignFilterChip: some View {
let scoped = !store.campaignFilter.isEmpty
return HStack(spacing: 7) {
Button {
filterOpen = true
} label: {
HStack(spacing: 6) {
Image(systemName: "megaphone.fill")
.font(.system(size: 11, weight: .semibold))
Text(scopeLabel)
.font(.footnote.weight(.semibold))
.lineLimit(1)
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .bold))
}
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.white.opacity(scoped ? 0.26 : 0.12), in: Capsule())
}
.buttonStyle(TapScaleStyle())
.accessibilityLabel("Campaign filter: \(scopeLabel)")
// Deliverability, warmup and accounts are not campaign data, so the filter skips them.
if scoped, scope != .overview {
Text("Headline numbers only")
.font(.caption.weight(.medium))
.foregroundStyle(.white.opacity(0.75))
.lineLimit(1)
}
if scoped {
Button {
withAnimation(.snappy) { store.campaignFilter = AnalyticsCampaignFilter() }
} label: {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
.frame(width: 26, height: 26)
.background(.white.opacity(0.12), in: Circle())
}
.buttonStyle(TapScaleStyle())
.accessibilityLabel("Show all campaigns")
}
}
.padding(.horizontal, 4)
}
private var heroStats: some View {
let stats = store.dashboard?.overallStats
return HStack(spacing: 10) {
@@ -478,7 +539,9 @@ struct AnalyticsRootView: View {
private var activeRow: some View {
let stats = store.dashboard?.overallStats
return Text("\(stats?.activeCampaigns ?? 0) active campaigns · \(stats?.activeAccounts ?? 0) sending accounts")
// Sending accounts are never narrowed by the campaign filter; say so when it is on.
let accounts = store.campaignFilter.isEmpty ? "sending accounts" : "sending accounts in workspace"
return Text("\(stats?.activeCampaigns ?? 0) active campaigns · \(stats?.activeAccounts ?? 0) \(accounts)")
.font(.footnote.weight(.medium))
.monospacedDigit()
.foregroundStyle(.secondary)
@@ -6,6 +6,8 @@ import Foundation
@Observable
final class AnalyticsStore {
var period: AnalyticsPeriod = .week
/// Narrows the overview's campaign sections; mailbox health stays workspace-wide.
var campaignFilter = AnalyticsCampaignFilter()
private(set) var dashboard: DashboardAnalytics?
private(set) var deliverability: DeliverabilitySummary?
@@ -40,15 +42,57 @@ final class AnalyticsStore {
isLoading = false
}
/// Reloads only the overview, for a filter change.
func reloadDashboard(_ api: APIClient) async {
await loadDashboard(api)
}
/// "All campaigns", a folder or campaign name, or a count.
func scopeLabel(folderName: (String) -> String?) -> String {
let filter = campaignFilter
if filter.isEmpty { return "All campaigns" }
let scope = dashboard?.scope
let count = scope?.campaignCount
let total = count.map { " · \($0) campaign\($0 == 1 ? "" : "s")" } ?? ""
if filter.campaigns.isEmpty, filter.folders.count == 1, let id = filter.folders.first {
let name = scope?.folders?.first { $0.id == id }?.name ?? folderName(id)
return (name ?? "1 folder") + total
}
if filter.folders.isEmpty, filter.campaigns.count == 1, let id = filter.campaigns.first {
return scope?.campaigns?.first { $0.id == id }?.name ?? "1 campaign"
}
if filter.folders.isEmpty {
let n = count ?? filter.campaigns.count
return "\(n) campaign\(n == 1 ? "" : "s")"
}
var parts = ["\(filter.folders.count) folder\(filter.folders.count == 1 ? "" : "s")"]
if !filter.campaigns.isEmpty {
parts.append("\(filter.campaigns.count) campaign\(filter.campaigns.count == 1 ? "" : "s")")
}
return parts.joined(separator: " + ") + total
}
private func loadDashboard(_ api: APIClient) async {
let filter = campaignFilter
var query: [String: String?] = ["period": period.rawValue]
if !filter.campaigns.isEmpty { query["campaign_ids"] = filter.campaigns.sorted().joined(separator: ",") }
if !filter.folders.isEmpty { query["folder_ids"] = filter.folders.sorted().joined(separator: ",") }
do {
let result: DashboardAnalytics = try await api.get(
"analytics/dashboard",
query: ["period": period.rawValue]
)
let result: DashboardAnalytics = try await api.get("analytics/dashboard", query: query)
// A newer filter is already on its way; this answer describes the old one.
guard filter == campaignFilter else { return }
dashboard = result
dashboardError = nil
// Drop selections the server no longer recognises (deleted, or another workspace's).
if let scope = result.scope {
let campaigns = Set((scope.campaigns ?? []).map(\.id)).intersection(filter.campaigns)
let folders = Set((scope.folders ?? []).map(\.id)).intersection(filter.folders)
if campaigns != filter.campaigns || folders != filter.folders {
campaignFilter = AnalyticsCampaignFilter(campaigns: campaigns, folders: folders)
}
}
} catch {
guard filter == campaignFilter else { return }
dashboardError = error.localizedDescription
}
}
@@ -16,6 +16,7 @@ function Owner({ children }: { children: ReactNode }) {
const cases: { name: string; schema: z.ZodType; initial: unknown; value: unknown }[] = [
{ name: "analytics.range", schema: schemas.analyticsRange, initial: "7d", value: "90d" },
{ name: "analytics.hiddenMetrics", schema: schemas.analyticsHiddenMetrics, initial: [], value: ["sent", "replies"] },
{ name: "analytics.campaigns", schema: schemas.analyticsCampaignFilter, initial: { campaigns: [], folders: [] }, value: { campaigns: ["c1", "c2"], folders: ["f1"] } },
{ name: "deliverability.view", schema: schemas.deliverabilityView, initial: { range: "7d", hidden: [] }, value: { range: "30d", hidden: ["providers", "mailboxes"] } },
{ name: "deliverability.hiddenMetrics", schema: schemas.deliverabilityHiddenMetrics, initial: ["sent"], value: ["opens", "replies"] },
{ name: "emails.search", schema: schemas.browseString, initial: "", value: "alice" },
@@ -83,6 +84,8 @@ describe("accounts and analytics browse schemas", () => {
expect(schemas.mailboxTab.safeParse("old").success).toBe(false);
expect(schemas.domainTab.safeParse("old").success).toBe(false);
expect(schemas.batchSenderStatus.safeParse("needs_signin").success).toBe(false);
expect(schemas.analyticsCampaignFilter.safeParse({ campaigns: [], folders: [], tags: [] }).success).toBe(false);
expect(schemas.analyticsCampaignFilter.safeParse({ campaigns: "c1", folders: [] }).success).toBe(false);
expect(schemas.analyticsHiddenMetrics.safeParse(["sent", "sent"]).success).toBe(false);
expect(schemas.analyticsHiddenMetrics.safeParse(["sent", "opens", "clicks", "replies"]).success).toBe(false);
expect(schemas.deliverabilityHiddenMetrics.safeParse(["sent", "opens", "replies", "bounces", "complaints"]).success).toBe(false);
+54 -11
View File
@@ -1,8 +1,9 @@
import { NoAccess } from "@/components/layout/NoAccess";
import { usePermission } from "@/hooks/usePermission";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import useBrowseState from "@/hooks/useBrowseState";
import { analyticsRange, analyticsHiddenMetrics } from "@/lib/browse-accounts-analytics";
import { useUserProfile } from "@/hooks/context/user";
import { analyticsRange, analyticsHiddenMetrics, analyticsCampaignFilter } from "@/lib/browse-accounts-analytics";
import { Link } from "@tanstack/react-router";
import {
ActivityIcon,
@@ -29,13 +30,16 @@ import { MultiTrend, type TrendSeries } from "@/components/ui/charts";
import { TONE_DOT } from "@/components/ui/tones";
import type { DitherTone } from "@/components/ui/dither";
import AnalyticsShareButton from "@/components/app/analytics/AnalyticsShareButton";
import CampaignScopePicker from "@/components/app/analytics/CampaignScopePicker";
import { ALL_CAMPAIGNS, isScoped, scopeLabel } from "@/components/app/analytics/campaignScope";
import useDashboard from "@/lib/api/hooks/app/analytics/useDashboard";
import DirectMailSection from "@/components/app/analytics/DirectMailSection";
import OriginBadge from "@/components/app/engagement/OriginBadge";
import type { RecentActivityItem } from "@/lib/api/models/app/analytics/DashboardOverview";
import type { DashboardCampaignFilter, RecentActivityItem } from "@/lib/api/models/app/analytics/DashboardOverview";
const AUTO_OPENS_TIP = "Auto-opens: pixel fetches from privacy proxies (e.g. Apple Mail) or within seconds of sending, not a person reading. Logged as delivery proof, not counted as opens";
const AUTO_CLICKS_TIP = "Auto-clicks: links followed by a security gateway scanning the email, not a person; not counted as clicks";
const UNFILTERED_TIP = "Not campaign data, so the campaign filter does not apply";
type Range = "7d" | "30d" | "90d";
type Metric = "sent" | "opens" | "clicks" | "replies";
@@ -65,9 +69,24 @@ export default function AnalyticsPage() {
const [range, setRange] = useBrowseState<Range>("analytics.range", "7d", analyticsRange);
// Legend toggles: every metric charts together; hidden ones drop out.
const [hiddenMetrics, setHiddenMetrics] = useBrowseState<Metric[]>("analytics.hiddenMetrics", [], analyticsHiddenMetrics);
const dash = useDashboard(range);
const [filter, setFilter] = useBrowseState<DashboardCampaignFilter>("analytics.campaigns", ALL_CAMPAIGNS, analyticsCampaignFilter);
const dash = useDashboard(range, filter);
const d = dash.data;
const os = d?.overall_stats;
const scoped = isScoped(filter);
const { user } = useUserProfile();
const label = scopeLabel(filter, d?.scope, (id) => user.folders?.find((f) => f.id === id)?.title, () => undefined);
// Drop selections the server no longer recognises (deleted, or moved out of the workspace).
const echoed = dash.isPlaceholderData ? undefined : d?.scope;
useEffect(() => {
if (!echoed) return;
const campaigns = filter.campaigns.filter((id) => echoed.campaigns.some((c) => c.id === id));
const folders = filter.folders.filter((id) => echoed.folders.some((f) => f.id === id));
if (campaigns.length !== filter.campaigns.length || folders.length !== filter.folders.length) {
setFilter({ campaigns, folders });
}
}, [echoed, filter, setFilter]);
const toggleMetric = (k: Metric) =>
setHiddenMetrics((cur) => {
@@ -97,7 +116,7 @@ export default function AnalyticsPage() {
];
const shareData = {
title: "Workspace performance",
title: scoped ? label : "Workspace performance",
subtitle: RANGE_LABEL[range],
metrics: [
{ label: "Sent", value: num(os?.total_emails_sent), sub: "emails" },
@@ -112,7 +131,11 @@ export default function AnalyticsPage() {
return (
<Page>
<PageTopbar eyebrow="Analytics" subtitle="Deliverability across the workspace">
<PageTopbar eyebrow="Analytics" subtitle={scoped ? "Selected campaigns" : "Deliverability across the workspace"}>
{dash.isPlaceholderData && dash.isFetching && (
<Loader2Icon className="w-3.5 h-3.5 text-slate-400 animate-spin" aria-label="Updating" />
)}
<CampaignScopePicker value={filter} onChange={setFilter} scope={d?.scope} />
<RangeTabs value={range} onChange={setRange} />
<AnalyticsShareButton data={shareData} filename={`warmbly-analytics-${range}.png`} />
</PageTopbar>
@@ -161,7 +184,7 @@ export default function AnalyticsPage() {
labels={trend.labels}
series={trend.series}
height={280}
emptyLabel="No sends in this window yet"
emptyLabel={scoped ? "No sends from these campaigns in this window" : "No sends in this window yet"}
/>
)}
</div>
@@ -190,7 +213,9 @@ export default function AnalyticsPage() {
</div>
{d?.account_health && (
<>
<SectionBar label="Account health" />
<SectionBar label="Account health">
{scoped && <WorkspaceWide />}
</SectionBar>
<div className="px-4 py-3 grid grid-cols-3 gap-2 text-center">
<HealthCell n={d.account_health.healthy_accounts} label="Healthy" tone="text-emerald-600" />
<HealthCell n={d.account_health.warning_accounts} label="At risk" tone="text-amber-600" />
@@ -221,7 +246,11 @@ export default function AnalyticsPage() {
))}
</div>
) : (d?.top_campaigns?.length ?? 0) === 0 ? (
<EmptyBlock title="No campaign sends yet" body="Once a campaign starts sending, your best performers show up here." />
scoped ? (
<EmptyBlock title="No sends in this window" body="None of the selected campaigns sent in this period. Try a longer period or another selection." />
) : (
<EmptyBlock title="No campaign sends yet" body="Once a campaign starts sending, your best performers show up here." />
)
) : (
<div className="divide-y divide-slate-200/60">
{d!.top_campaigns.map((c) => {
@@ -247,7 +276,7 @@ export default function AnalyticsPage() {
</div>
)}
<DirectMailSection period={range} />
<DirectMailSection period={range} unfiltered={scoped} />
<SectionBar label="Recent activity" />
<PageBody>
@@ -261,7 +290,12 @@ export default function AnalyticsPage() {
))}
</div>
) : (d?.recent_activity?.length ?? 0) === 0 ? (
<EmptyBlock title="Nothing yet" body="Opens, clicks, replies and bounces will stream in here as your campaigns send." />
<EmptyBlock
title="Nothing yet"
body={scoped
? "Opens, clicks, replies and bounces from the selected campaigns will stream in here."
: "Opens, clicks, replies and bounces will stream in here as your campaigns send."}
/>
) : (
<div className="divide-y divide-slate-200/60">
{d!.recent_activity.map((a, i) => (
@@ -276,6 +310,15 @@ export default function AnalyticsPage() {
);
}
// Marks a section the campaign filter does not narrow, so it never reads as filtered.
function WorkspaceWide() {
return (
<span className="text-[10.5px] text-slate-400" title={UNFILTERED_TIP}>
Workspace-wide
</span>
);
}
function HealthCell({ n, label, tone }: { n: number; label: string; tone: string }) {
return (
<div>
@@ -0,0 +1,129 @@
import React from "react";
import { MegaphoneIcon } from "lucide-react";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuLabel,
PopoverMenuSeparator,
PopoverMenuTrigger,
SelectButton,
} from "@/components/ui/popover-menu";
import { CheckSquare } from "@/components/ui/check-square";
import { useUserProfile } from "@/hooks/context/user";
import useDebouncedValue from "@/hooks/useDebouncedValue";
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
import type { DashboardCampaignFilter, DashboardScope } from "@/lib/api/models/app/analytics/DashboardOverview";
import { ALL_CAMPAIGNS, isScoped, scopeLabel } from "./campaignScope";
// The Analytics page's campaign filter: every campaign, or any mix of
// folders and campaigns, which the server unions and counts once each.
export default function CampaignScopePicker({
value,
onChange,
scope,
}: {
value: DashboardCampaignFilter;
onChange: (next: DashboardCampaignFilter) => void;
scope?: DashboardScope;
}) {
const { user } = useUserProfile();
const folders = user.folders ?? [];
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const search = useDebouncedValue(query.trim());
// Only listed while the menu is open; the trigger reads names from the server's echo.
const list = useCampaigns({ query: search, folder: "", enabled: open });
const needle = query.trim().toLowerCase();
const shownFolders = needle ? folders.filter((f) => f.title.toLowerCase().includes(needle)) : folders;
const label = scopeLabel(
value,
scope,
(id) => folders.find((f) => f.id === id)?.title,
(id) => list.campaigns.find((c) => c.id === id)?.name,
);
const toggle = (key: keyof DashboardCampaignFilter, id: string) => {
const current = value[key];
onChange({ ...value, [key]: current.includes(id) ? current.filter((x) => x !== id) : [...current, id] });
};
return (
<PopoverMenu
align="end"
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) setQuery("");
}}
>
<PopoverMenuTrigger asChild>
<SelectButton
icon={<MegaphoneIcon className="w-3 h-3" />}
label={label}
title={label}
aria-label={`Campaign filter: ${label}`}
className={isScoped(value) ? "border-sky-300 bg-sky-50 text-sky-800 hover:border-sky-400" : undefined}
/>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={300} className="max-h-[min(440px,calc(100dvh-16px))]">
<div className="px-2 py-1.5 border-b border-slate-200">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search campaigns and folders…"
aria-label="Search campaigns and folders"
className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none"
/>
</div>
<PopoverMenuItem selected={!isScoped(value)} onSelect={() => onChange(ALL_CAMPAIGNS)}>
All campaigns
</PopoverMenuItem>
{shownFolders.length > 0 && (
<>
<PopoverMenuSeparator />
<PopoverMenuLabel>Folders</PopoverMenuLabel>
{shownFolders.map((f) => (
<PopoverMenuItem
key={f.id}
closeOnSelect={false}
icon={<CheckSquare checked={value.folders.includes(f.id)} />}
trailing={<span className="block size-2 rounded-full" style={{ backgroundColor: f.color }} />}
onSelect={() => toggle("folders", f.id)}
>
{f.title}
</PopoverMenuItem>
))}
</>
)}
<PopoverMenuSeparator />
<PopoverMenuLabel>Campaigns</PopoverMenuLabel>
{list.campaigns.map((c) => (
<PopoverMenuItem
key={c.id}
closeOnSelect={false}
icon={<CheckSquare checked={value.campaigns.includes(c.id)} />}
trailing={
<span
className={`block size-1.5 rounded-full ${
c.status === "active" ? "bg-emerald-500" : c.status === "paused" ? "bg-amber-500" : "bg-slate-300"
}`}
/>
}
onSelect={() => toggle("campaigns", c.id)}
>
{c.name}
</PopoverMenuItem>
))}
{list.campaigns.length === 0 && (
<div className="px-3 py-2 text-[11.5px] text-slate-400">
{list.isPending ? "Loading…" : list.isError ? "Couldn't load campaigns." : "No campaigns found."}
</div>
)}
</PopoverMenuContent>
</PopoverMenu>
);
}
@@ -51,7 +51,8 @@ function SkeletonRows({ rows = 3 }: { rows?: number }) {
);
}
export default function DirectMailSection({ period }: { period: string }) {
// unfiltered marks the section while the page is narrowed to campaigns: direct mail belongs to none.
export default function DirectMailSection({ period, unfiltered = false }: { period: string; unfiltered?: boolean }) {
const q = useDirectMail(period);
const d: DirectMailAnalytics | undefined = q.data;
const vol = d?.volume;
@@ -78,6 +79,11 @@ export default function DirectMailSection({ period }: { period: string }) {
return (
<>
<SectionBar label="Direct mail">
{unfiltered && (
<span className="text-[10.5px] text-slate-400" title="Direct mail belongs to no campaign, so the campaign filter does not apply">
Not filtered by campaign
</span>
)}
<Link
to="/app/unibox/{-$scope}/{-$threadId}"
params={{ scope: "sent", threadId: undefined }}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { ALL_CAMPAIGNS, isScoped, scopeLabel } from "./campaignScope";
const folderName = (id: string) => ({ f1: "Client A", f2: "Client B" })[id];
const campaignName = (id: string) => ({ c1: "Dentists" })[id];
describe("campaign scope label", () => {
it("reads all campaigns when nothing is selected", () => {
expect(isScoped(ALL_CAMPAIGNS)).toBe(false);
expect(scopeLabel(ALL_CAMPAIGNS, undefined, folderName, campaignName)).toBe("All campaigns");
});
it("names a folder with the campaigns the server resolved", () => {
const filter = { campaigns: [], folders: ["f1"] };
expect(scopeLabel(filter, undefined, folderName, campaignName)).toBe("Client A");
const scope = { campaigns: [], folders: [{ id: "f1", name: "Client A" }], campaign_count: 6 };
expect(scopeLabel(filter, scope, folderName, campaignName)).toBe("Client A · 6 campaigns");
});
it("names one campaign, and counts several", () => {
expect(scopeLabel({ campaigns: ["c1"], folders: [] }, undefined, folderName, campaignName)).toBe("Dentists");
expect(scopeLabel({ campaigns: ["c1", "c2", "c3"], folders: [] }, undefined, folderName, campaignName)).toBe("3 campaigns");
});
it("counts a mix once, by the server's deduplicated total", () => {
const filter = { campaigns: ["c1"], folders: ["f1", "f2"] };
const scope = { campaigns: [{ id: "c1", name: "Dentists" }], folders: [], campaign_count: 1 };
expect(scopeLabel(filter, scope, folderName, campaignName)).toBe("2 folders + 1 campaign · 1 campaign");
});
});
@@ -0,0 +1,37 @@
import type { DashboardCampaignFilter, DashboardScope } from "@/lib/api/models/app/analytics/DashboardOverview";
export const ALL_CAMPAIGNS: DashboardCampaignFilter = { campaigns: [], folders: [] };
export function isScoped(filter: DashboardCampaignFilter): boolean {
return filter.campaigns.length > 0 || filter.folders.length > 0;
}
function plural(n: number, word: string): string {
return `${n.toLocaleString()} ${word}${n === 1 ? "" : "s"}`;
}
// scopeLabel names a filter: "All campaigns", "Client A · 6 campaigns",
// one campaign's name, or a count. Names come from the server's echo first.
export function scopeLabel(
filter: DashboardCampaignFilter,
scope: DashboardScope | undefined,
folderName: (id: string) => string | undefined,
campaignName: (id: string) => string | undefined,
): string {
if (!isScoped(filter)) return "All campaigns";
const total = scope ? ` · ${plural(scope.campaign_count, "campaign")}` : "";
const { campaigns, folders } = filter;
if (folders.length === 1 && campaigns.length === 0) {
const id = folders[0];
const name = scope?.folders.find((f) => f.id === id)?.name ?? folderName(id);
return `${name ?? "1 folder"}${total}`;
}
if (campaigns.length === 1 && folders.length === 0) {
const id = campaigns[0];
return scope?.campaigns.find((c) => c.id === id)?.name ?? campaignName(id) ?? "1 campaign";
}
if (folders.length === 0) return plural(scope?.campaign_count ?? campaigns.length, "campaign");
const parts = [plural(folders.length, "folder")];
if (campaigns.length) parts.push(plural(campaigns.length, "campaign"));
return `${parts.join(" + ")}${total}`;
}
@@ -1,12 +1,16 @@
import type DashboardOverview from "@/lib/api/models/app/analytics/DashboardOverview";
import type { DashboardCampaignFilter } from "@/lib/api/models/app/analytics/DashboardOverview";
import Request from "../../Request";
// Workspace dashboard analytics. Returned as a bare object (no {data} envelope),
// so no unwrap is needed here — but the period must be forwarded.
export default async function getDashboard(period: string = "7d"): Promise<DashboardOverview> {
export default async function getDashboard(period: string = "7d", filter?: DashboardCampaignFilter): Promise<DashboardOverview> {
const params = new URLSearchParams({ period });
if (filter?.campaigns.length) params.set("campaign_ids", filter.campaigns.join(","));
if (filter?.folders.length) params.set("folder_ids", filter.folders.join(","));
return await Request<DashboardOverview>({
method: "GET",
url: `/analytics/dashboard?period=${encodeURIComponent(period)}`,
url: `/analytics/dashboard?${params.toString()}`,
authorization: true,
})
}
@@ -1,12 +1,19 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { keepPreviousData, queryOptions, useQuery } from "@tanstack/react-query";
import getDashboard from "@/lib/api/client/app/analytics/getDashboard";
import type { DashboardCampaignFilter } from "@/lib/api/models/app/analytics/DashboardOverview";
export const dashboardQuery = (period: string = "7d") =>
queryOptions({
queryKey: ["analytics", "dashboard", period],
queryFn: () => getDashboard(period),
// The unfiltered key stays ["analytics", "dashboard", period], shared with the sidebar meter.
export const dashboardQuery = (period: string = "7d", filter?: DashboardCampaignFilter) => {
const scoped = !!filter && (filter.campaigns.length > 0 || filter.folders.length > 0);
return queryOptions({
queryKey: scoped
? ["analytics", "dashboard", period, { campaigns: [...filter.campaigns].sort(), folders: [...filter.folders].sort() }]
: ["analytics", "dashboard", period],
queryFn: () => getDashboard(period, scoped ? filter : undefined),
});
};
export default function useDashboard(period: string = "7d") {
return useQuery(dashboardQuery(period))
export default function useDashboard(period: string = "7d", filter?: DashboardCampaignFilter) {
// Changing the filter keeps the last numbers on screen until the new ones land.
return useQuery({ ...dashboardQuery(period, filter), placeholderData: keepPreviousData })
}
@@ -1,7 +1,7 @@
import type { WorkspaceSendCapacity } from "@/lib/api/models/app/campaigns/SendPlan"
import type { EngagementOrigin } from "@/lib/api/models/app/contacts/ContactTimelineEvent"
// GET /analytics/dashboard?period=7d|30d|90d — a single (un-enveloped) object
// GET /analytics/dashboard?period=7d|30d|90d[&campaign_ids][&folder_ids] — a single (un-enveloped) object
// mirroring the backend models.DashboardAnalytics. The previous flat shape
// (total_campaigns/total_contacts…) did not match the wire body.
@@ -74,4 +74,26 @@ export default interface DashboardOverview {
// What the workspace's mailboxes can send today under the scheduler's
// clamps; the sidebar meter's denominator. Absent when not computed.
capacity_today?: WorkspaceSendCapacity
// The campaign filter the campaign sections were computed for; absent
// when they cover the whole workspace.
scope?: DashboardScope
}
// What the page asks for: campaign and folder ids, unioned by the server.
export interface DashboardCampaignFilter {
campaigns: string[]
folders: string[]
}
export interface DashboardScopeItem {
id: string
name: string
}
// The requested campaigns and folders that belong to the workspace, and how
// many distinct campaigns they cover together.
export interface DashboardScope {
campaigns: DashboardScopeItem[]
folders: DashboardScopeItem[]
campaign_count: number
}
+4
View File
@@ -11,6 +11,10 @@ export const mailboxSort = z.strictObject({
by: z.enum(["mailbox", "status", "sent", "warmup", "inbox", "health"]),
reverse: z.boolean(),
}).nullable();
export const analyticsCampaignFilter = z.strictObject({
campaigns: z.array(z.string()).max(100),
folders: z.array(z.string()).max(100),
});
export const analyticsHiddenMetrics = z.array(z.enum(["sent", "opens", "clicks", "replies"]))
.max(3).refine((values) => new Set(values).size === values.length);
export const deliverabilityHiddenMetrics = z.array(z.enum(["bounces", "complaints", "opens", "replies", "sent"]))