Merge remote-tracking branch 'origin/main' into feature/batch-inbox-placement-tests

This commit is contained in:
Matthew Meszaros
2026-09-29 10:51:41 -07:00
72 changed files with 4891 additions and 454 deletions
+40
View File
@@ -318,6 +318,46 @@ the hold. Resuming a lead that is not held succeeds and changes nothing.`,
{Name: "contact", Help: "The contact's id"},
},
},
{
Name: "lead-cc", Short: "Contacts copied on one lead's emails",
Method: http.MethodGet, Path: "/campaigns/{id}/leads/{contact}/cc",
Args: []argSpec{
{Name: "id", Help: "The campaign's id"},
{Name: "contact", Help: "The lead's contact id"},
},
Table: output.Table{Root: "cc", Columns: []output.Column{
col("CONTACT", "contact_id"), col("EMAIL", "email"), col("STATUS", "status"),
}, Empty: "This lead copies nobody."},
},
{
Name: "set-lead-cc", Short: "Replace the contacts copied on one lead's emails",
Long: `Copy up to two contacts on every email this campaign sends one lead, follow-ups
included, so colleagues at one company share a single thread. The list replaces
the current one. A copied contact who is also a lead of the campaign has their
own sequence held while any lead copies them, so they never get two threads.`,
Example: " $ warmbly campaign set-lead-cc CAMPAIGN_ID CONTACT_ID --cc COLLEAGUE_ID\n" +
" $ warmbly campaign set-lead-cc CAMPAIGN_ID CONTACT_ID --input '{\"contact_ids\":[]}' # copy nobody",
Method: http.MethodPut, Path: "/campaigns/{id}/leads/{contact}/cc", Body: bodyRequired,
Args: []argSpec{
{Name: "id", Help: "The campaign's id"},
{Name: "contact", Help: "The lead's contact id"},
},
Flag: []flagSpec{
{Name: "cc", Help: "A contact id to copy (repeatable, at most 2)", Kind: flagStrings, Key: "contact_ids"},
},
Success: "Lead CC replaced.",
},
{
Name: "lead-cc-suggestions", Short: "The lead's likely colleagues to copy",
Method: http.MethodGet, Path: "/campaigns/{id}/leads/{contact}/cc/suggestions",
Args: []argSpec{
{Name: "id", Help: "The campaign's id"},
{Name: "contact", Help: "The lead's contact id"},
},
Table: output.Table{Root: "data", Columns: []output.Column{
col("CONTACT", "contact_id"), col("EMAIL", "email"), col("COMPANY", "company"), col("MATCH", "reason"),
}, Empty: "No contacts share the lead's company or email domain."},
},
{
Name: "logs", Short: "The campaign's send log",
Method: http.MethodGet, Path: "/campaigns/{id}/logs", Paginate: true,
+5
View File
@@ -72,6 +72,11 @@ var apiSpecs = []apiSpec{
{name: "campaign lead-hold", summary: "Whether one lead's flow is held", method: "GET", path: "/campaigns/{id}/leads/{child}/hold", child: "contact"},
{name: "campaign pause-lead", summary: "Hold one lead's flow until a date, or until resumed", method: "POST", path: "/campaigns/{id}/leads/{child}/pause", body: bodyOptional, child: "contact"},
{name: "campaign resume-lead", summary: "Lift one lead's hold now", method: "POST", path: "/campaigns/{id}/leads/{child}/resume", child: "contact"},
// Contacts copied on every email to one lead. --data carries
// {"contact_ids": ["<contact>", ...]}, at most two; [] copies nobody.
{name: "campaign lead-cc", summary: "Contacts copied on one lead's emails", method: "GET", path: "/campaigns/{id}/leads/{child}/cc", child: "contact"},
{name: "campaign set-lead-cc", summary: "Replace the contacts copied on one lead's emails", method: "PUT", path: "/campaigns/{id}/leads/{child}/cc", body: bodyRequired, child: "contact"},
{name: "campaign lead-cc-suggestions", summary: "The lead's likely colleagues to copy", method: "GET", path: "/campaigns/{id}/leads/{child}/cc/suggestions", child: "contact"},
// Contacts.
{name: "contact list", summary: "List or search contacts; --data carries the filter body", method: "POST", path: "/contacts/search", body: bodyOptional, query: []string{"limit", "cursor"}},
+5 -1
View File
@@ -238,7 +238,7 @@ Run `warmbly <command> --help` for the flags, and `warmbly <command> <subcommand
| `auth` | login, logout, status, token, switch, refresh |
| `status` | one screen: mailboxes needing attention, what is sending, what is unread |
| `browse` | open the dashboard, or one record, in a browser |
| `campaign` | list, view, create, edit, steps, senders, segments, preflight, test, start, stop, logs, plan, pause-lead / resume-lead / lead-hold |
| `campaign` | list, view, create, edit, steps, senders, segments, preflight, test, start, stop, logs, plan, pause-lead / resume-lead / lead-hold, lead-cc / set-lead-cc / lead-cc-suggestions |
| `contact` | list, view, create, edit, delete, lookup, timeline, notes, import, export, verify |
| `mailbox` | list, view, edit, health checks, sync state, send-as addresses, sending behaviour, warmup, hold, send |
| `inbox` | list, view, threads, read, reply, compose, drafts, scheduled sends, snoozes |
@@ -289,6 +289,10 @@ warmbly mailbox edit MAILBOX_ID --send-as hello@acme.com
warmbly campaign pause-lead CAMPAIGN_ID CONTACT_ID --until 2026-09-21T17:00:00Z --reason "On holiday"
warmbly campaign resume-lead CAMPAIGN_ID CONTACT_ID
# Two people at one company: copy the second on the first lead's emails
warmbly campaign lead-cc-suggestions CAMPAIGN_ID CONTACT_ID
warmbly campaign set-lead-cc CAMPAIGN_ID CONTACT_ID --cc COLLEAGUE_ID
# The inbox
warmbly inbox list --unseen --limit 20
warmbly inbox thread --email-id EMAIL_ID
+6 -1
View File
@@ -78,6 +78,9 @@ Campaigns, steps and activity logs are scoped to the selected organization for s
| GET | `/campaigns/:id/leads/:contactId/hold` | `READ_CAMPAIGNS` |
| POST | `/campaigns/:id/leads/:contactId/pause` | `WRITE_CAMPAIGNS` |
| POST | `/campaigns/:id/leads/:contactId/resume` | `WRITE_CAMPAIGNS` |
| GET | `/campaigns/:id/leads/:contactId/cc` | `READ_CAMPAIGNS` + `READ_CONTACTS` |
| PUT | `/campaigns/:id/leads/:contactId/cc` | `WRITE_CAMPAIGNS` + `READ_CONTACTS` |
| GET | `/campaigns/:id/leads/:contactId/cc/suggestions` | `READ_CAMPAIGNS` + `READ_CONTACTS` |
| GET | `/campaigns/:id/logs` | `READ_CAMPAIGNS` |
| GET | `/campaigns/:id/send-plan` | `READ_CAMPAIGNS` |
| GET | `/campaigns/:id/forms` | `READ_CAMPAIGNS` |
@@ -90,7 +93,9 @@ Campaigns, steps and activity logs are scoped to the selected organization for s
| POST | `/email-images` | `WRITE_CAMPAIGNS` |
| DELETE | `/email-images/:id` | `WRITE_CAMPAIGNS` |
The three `/campaigns/:id/leads/:contactId/…` calls hold one contact's flow inside one campaign: an out-of-office auto-reply writes one automatically, and a member can write one by hand. The contact stays subscribed and stays a lead, so this is not an unsubscribe and not a suppression. Both writes state an absolute hold rather than applying a delta, and replacing a live hold keeps its original start, so a retry lands on the same row and neither needs an `Idempotency-Key`. See [pause a lead](/api/reference/campaigns/#pause-a-lead).
The `hold`, `pause` and `resume` calls under `/campaigns/:id/leads/:contactId/` hold one contact's flow inside one campaign: an out-of-office auto-reply writes one automatically, and a member can write one by hand. The contact stays subscribed and stays a lead, so this is not an unsubscribe and not a suppression. Both writes state an absolute hold rather than applying a delta, and replacing a live hold keeps its original start, so a retry lands on the same row and neither needs an `Idempotency-Key`. See [pause a lead](/api/reference/campaigns/#pause-a-lead).
The `cc` calls under the same path set the contacts copied on every email to one lead. Their answers carry contact names and addresses, so they need contact read access as well as the campaign scope. `PUT` sends the whole list, so a retry lands on the same state and needs no `Idempotency-Key`. See [set a lead's CC](/api/reference/campaigns/#set-a-leads-cc).
`GET /campaigns/:id/forms` reports the forms this campaign links to and what its recipients did with them: personalized links handed out, who opened one, who started filling it in and who submitted. See the [forms guide](/guides/forms/).
+5
View File
@@ -111,6 +111,8 @@ Fields are named by their JSON key, with nested fields as a dotted path (`inner.
| `invalid_slug` | `PATCH /organization/current` was given a `slug` that is not 2 to 80 lowercase letters, numbers or dashes starting and ending with a letter or number |
| `invalid_sync_folder` | `PUT /emails/:id/sync` was given a folder the sync always follows (`INBOX`, or a sent, drafts, spam, trash or archive folder by attribute or name), a name that is empty after trimming, longer than 255 characters or carrying a control character, more than 50 names, or a mailbox that is not IMAP. The `message` names the entry refused |
| `no_organization` | The request needs a workspace and the caller has none selected. Every entitlement, limit and suppression rule is scoped to a workspace, so a write that would run unscoped is refused rather than run without those checks. API keys always carry their workspace; a dashboard session picks one at sign-in, so this normally means the session predates the workspace being chosen. Select a workspace and retry |
| `lead_cc_limit` | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) was given more than `2` contacts |
| `lead_cc_self` | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) named the lead itself as a copy |
#### Password refusals
@@ -445,6 +447,7 @@ Returned when the requested resource doesn't exist.
| `code` | Meaning |
|--------|---------|
| `unknown_view` | `/me/views/:view` was given a view name other than `contacts` or `campaign_leads` |
| `lead_cc_contact_not_found` | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) named a contact that is not in the workspace |
### 409 Conflict
@@ -473,6 +476,8 @@ A few conflicts carry their own `code`. The mailbox import's are under [mailbox
| `code` | Status | Meaning |
|--------|--------|---------|
| `contact_email_taken` | 409 | The address given to [update a contact](/api/reference/contacts/#update-a-contact) already belongs to another contact |
| `lead_cc_lead_is_copied` | 409 | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) on a lead that is itself copied on another lead in the campaign, so it sends nothing of its own to copy anyone on |
| `lead_cc_has_copies` | 409 | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) names a contact that has copies of their own in the campaign |
| `mailbox_is_seed` | 409 | Warmup was started or resumed on a placement seed inbox. See [placement test refusals](#placement-test-refusals) |
| `mailbox_not_google_signin` | 409 | `POST /emails/onboarding/app-password/:id` on a mailbox that is not connected with per-mailbox Google sign-in. See [app password switch refusals](#app-password-switch-refusals) |
| `mailbox_cloud_unenroll_failed` | 409 | The mailbox is linked to [Warmbly Cloud](/guides/warmbly-cloud/) and its link could not be released, so `DELETE /emails/{id}` would leave Warmbly Cloud holding its credential or its claim on it. The mailbox record remains, and restoration onto its worker is attempted |
+104 -3
View File
@@ -194,8 +194,8 @@ Create a campaign. Only `name` is required, every other field is optional and ap
| `daily_limit` | integer | no | Per-campaign daily send cap. |
| `unsubscribe_header` | boolean | no | Add the RFC 8058 one-click unsubscribe header. |
| `risky_emails` | boolean | no | Allow sending to risky/unverified addresses. |
| `cc` | string[] | no | Static CC list. |
| `bcc` | string[] | no | Static BCC list. |
| `cc` | string[] | no | Static CC list, on every email to every lead. An address that is suppressed, is the lead's own, or has bounced on this campaign is left off that email. To copy someone on one lead only, see [set a lead's CC](#set-a-leads-cc). |
| `bcc` | string[] | no | Static BCC list, filtered the same way. |
| `start_date` | string (RFC 3339), nullable | no | Earliest send time. Today or later; omit or send `null` to start as soon as the campaign is active. |
| `end_date` | string (RFC 3339), nullable | no | Latest send time. Must be in the future; omit or send `null` for an open-ended campaign. |
| `timezone` | string | no | IANA timezone for the schedule. Omit or send `""` to follow the workspace timezone, resolved on every read (UTC when none is set). The response carries the zone in use as `effective_timezone`. |
@@ -815,7 +815,7 @@ Read whether one contact's flow inside this campaign is currently held. A hold p
}
```
`hold` is absent when the lead is not held, including for a dated hold that has since expired. `until` is absent when the hold has no end, in which case only a resume lifts it. `source` is `out_of_office` or `manual`.
`hold` is absent when the lead is not held, including for a dated hold that has since expired. `until` is absent when the hold has no end, in which case only a resume lifts it. `source` is `out_of_office`, `inbox_tagging`, `manual`, or `cc` while the contact is [copied on another lead's emails](#set-a-leads-cc) in this campaign; a `cc` hold's `reason` is that lead's address.
### Errors
@@ -885,6 +885,107 @@ Resuming a lead that is not held succeeds and changes nothing, so a retry is saf
| --- | --- | --- |
| `404` | `not_found` | The campaign is not the caller's organization's, or the contact is not a lead of it. |
## Get a lead's CC
`GET /campaigns/:id/leads/:contact_id/cc`
List the contacts copied on every email this campaign sends one lead. See [copying colleagues on one lead](/guides/campaigns/#copying-colleagues-on-one-lead). **Scope** `READ_CAMPAIGNS` and `READ_CONTACTS` · **Org permission** `view_campaigns` and `view_contacts`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `contact_id` | path | uuid | Contact id. Must already be a lead of the campaign. |
### Response
```json
{
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"contact_id": "3a5e9c71-4f2b-4d88-9a0c-1b7e5d2f6c34",
"cc": [
{
"contact_id": "b61c0e84-2d9f-4a57-8e3b-6f0a1c2d4e59",
"email": "jonas@acme.example",
"first_name": "Jonas",
"last_name": "Weber",
"company": "Acme GmbH",
"status": "active"
}
]
}
```
`status` says whether the next email copies them: `active` does; `unsubscribed` (opted out or suppressed), `bounced` (bounced on this thread or on a campaign email of their own) and `undeliverable` (failed verification under the campaign's rules) are left off until that changes. `bounced_at` is set when a bounce was attributed to this copy on this lead's thread. The list is in the order it was set.
### Errors
| Status | Code | When |
| --- | --- | --- |
| `404` | `not_found` | The campaign is not the caller's organization's, or the contact is not a lead of it. |
## Set a lead's CC
`PUT /campaigns/:id/leads/:contact_id/cc`
Replace the contacts copied on every email this campaign sends one lead, follow-ups included. An empty list removes them all. **Scope** `WRITE_CAMPAIGNS` and `READ_CONTACTS` · **Org permission** `manage_campaigns` and `view_contacts`.
The body is the whole list, so a retry lands on the same state and no `Idempotency-Key` is needed.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `contact_id` | path | uuid | Contact id. Must already be a lead of the campaign. |
| `contact_ids` | body | array of uuid | Contacts of the workspace to copy, at most `2`. Duplicates are ignored. |
A copied contact who is also a lead of this campaign has their own sequence held with `source` `cc` for as long as any lead copies them, so they never get two threads from one campaign. Removing the copy releases the hold.
### Request body
```json
{ "contact_ids": ["b61c0e84-2d9f-4a57-8e3b-6f0a1c2d4e59"] }
```
### Response
Same shape as [get a lead's CC](#get-a-leads-cc).
### Errors
| Status | Code | When |
| --- | --- | --- |
| `400` | `bad_request` | A `contact_ids` entry is not a uuid. |
| `400` | `lead_cc_limit` | More than `2` contacts. |
| `400` | `lead_cc_self` | The lead is in its own list. |
| `404` | `not_found` | The campaign is not the caller's organization's, or the contact is not a lead of it. |
| `404` | `lead_cc_contact_not_found` | A contact to copy is not a contact of the workspace. |
| `409` | `lead_cc_lead_is_copied` | The lead is copied on another lead in this campaign, so it sends nothing of its own to copy anyone on. |
| `409` | `lead_cc_has_copies` | A contact to copy has copies of their own in this campaign. |
## Suggest colleagues to CC
`GET /campaigns/:id/leads/:contact_id/cc/suggestions`
Up to eight contacts who look like the lead's colleagues: the same company name, or the same email domain when that domain belongs to a company rather than a personal mail service. Unsubscribed contacts and ones already copied are left out. **Scope** `READ_CAMPAIGNS` and `READ_CONTACTS` · **Org permission** `view_campaigns` and `view_contacts`.
### Response
```json
{
"data": [
{
"contact_id": "b61c0e84-2d9f-4a57-8e3b-6f0a1c2d4e59",
"email": "jonas@acme.example",
"first_name": "Jonas",
"last_name": "Weber",
"company": "Acme GmbH",
"reason": "company"
}
]
}
```
`reason` is `company` when the company names match and `domain` when only the email domain does. Company matches come first.
## Get campaign logs
`GET /campaigns/:id/logs`
+2 -2
View File
@@ -102,7 +102,7 @@ Every contact carries `mail_host`, who hosts the inbox the address belongs to, a
`mail_host` is one of `google_workspace`, `gmail`, `microsoft365`, `outlook`, `zoho`, `yahoo`, `aol`, `icloud`, `fastmail`, `godaddy`, `namecheap`, `ionos`, `hostinger`, `ovh`, `migadu`, `purelymail`, `rackspace`, `yandex`, `gmx`, `proton`, or `other` (the domain receives mail, on a host Warmbly does not name), and is empty until the check has run or when the domain has no mail server. `esp_provider` is `gmail` for either Google product, `outlook` for either Microsoft one, `other` for the rest, and empty with `mail_host`. Campaign [ESP matching](/guides/campaigns/) pairs senders and recipients by `esp_provider`. A domain whose provider could not be read is checked again a week later; one whose lookup failed is retried within the hour.
When the search filters by exactly one campaign, each contact additionally carries a `campaign_lead` object with its processing state inside that campaign (`status`, `sent`, `opened`, `machine_opened`, `clicked`, `replied`, `bounced`, `current_step`, `sender`, `last_activity_at`, `hold` when held, and `failure_reason` when failed). `sender` is the mailbox address the lead's whole sequence sends from, fixed when its first email went out and absent until then. `opened` counts steps opened by a person; steps fetched automatically by a mail client (Apple Mail Privacy Protection and similar) are in `machine_opened` instead, matching the machine opens the analytics summary reports. The `status` derivation, highest priority first, is `unsubscribed` (not subscribed), then `bounced`, `replied`, `failed` (a step could not be sent after every retry; `failure_reason` carries the sending worker's reason), `completed` (every email step sent, no reply), `paused` (the lead's flow is held, by an out-of-office auto-reply or by hand; the `hold` object carries `since`, `until`, `reason` and `source`), `active` (some steps sent, more to send), `undeliverable` (pre-send verification refused the address, so the campaign skips the lead and never sends to it), and `pending` (queued, nothing sent). A step counts as sent only once the sending worker has delivered it to the mailbox provider; a send the worker could not complete is retried on the campaign's next pass and never shows as sent. The `lead_status` filter narrows to one of these buckets.
When the search filters by exactly one campaign, each contact additionally carries a `campaign_lead` object with its processing state inside that campaign (`status`, `sent`, `opened`, `machine_opened`, `clicked`, `replied`, `bounced`, `current_step`, `sender`, `last_activity_at`, `hold` when held, `cc` when the lead copies anyone, and `failure_reason` when failed). `cc` lists the contacts copied on every email to the lead, in the shape [get a lead's CC](/api/reference/campaigns/#get-a-leads-cc) returns. `sender` is the mailbox address the lead's whole sequence sends from, fixed when its first email went out and absent until then. `opened` counts steps opened by a person; steps fetched automatically by a mail client (Apple Mail Privacy Protection and similar) are in `machine_opened` instead, matching the machine opens the analytics summary reports. The `status` derivation, highest priority first, is `unsubscribed` (not subscribed), then `bounced`, `replied`, `failed` (a step could not be sent after every retry; `failure_reason` carries the sending worker's reason), `completed` (every email step sent, no reply), `paused` (the lead's flow is held, by an out-of-office auto-reply, by hand, or because the contact is copied on another lead's emails in the campaign; the `hold` object carries `since`, `until`, `reason` and `source`), `active` (some steps sent, more to send), `undeliverable` (pre-send verification refused the address, so the campaign skips the lead and never sends to it), and `pending` (queued, nothing sent). A step counts as sent only once the sending worker has delivered it to the mailbox provider; a send the worker could not complete is retried on the campaign's next pass and never shows as sent. The `lead_status` filter narrows to one of these buckets.
When the search filters by exactly one campaign, the first page (no `cursor`) also includes a `lead_counts` object: per-status lead totals for that campaign, independent of the `lead_status` and `engagement` filters so every scope's total is available at once. The status buckets include `paused`. Alongside them it carries engagement totals that match the `engagement` filter: `contacted` (leads sent at least one step), `opened` (a human open on any step), `clicked`, and `replied_any` (a reply on any step, whatever the derived status). `providers` splits the campaign's leads by [email provider](#email-provider) family, the grouping ESP matching uses: `gmail`, `outlook`, `other` (including checked domains with no known provider, which ESP matching treats the same way), and `undetected` for leads whose provider has not been read yet.
@@ -1008,7 +1008,7 @@ Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`
`sender_id` and `sender_email` are the mailbox this lead's whole sequence sends from. Rotation picks it when the first email goes out and every follow-up keeps it, so the contact only ever hears from one address; both fields are absent until that first email. They change only when that mailbox can no longer send for the campaign.
`lead_status` uses the same values as the campaign Leads view: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `paused`, `unsubscribed`, or `undeliverable`. A held lead also carries a `hold` object (`since`, `until`, `reason`, `source`) and keeps its `next` action, with the hold as the reason it is waiting; see [pause a lead](/api/reference/campaigns/#pause-a-lead). Each step carries whichever of `sent_at`, `opened_at`, `clicked_at`, `replied_at`, `bounced_at` and `failed_at` apply, plus `attempts` and `in_flight` (reserved for a worker whose result has not come back). `opened_at` is a person's open, as it is in the Leads view: a step a mail client prefetched or a security gateway scanned carries no `opened_at`. While a branch condition is undecided, `next.step_id` is absent and `next.step_label` says the step depends on the contact's response.
`lead_status` uses the same values as the campaign Leads view: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `paused`, `unsubscribed`, or `undeliverable`. A held lead also carries a `hold` object (`since`, `until`, `reason`, `source`) and keeps its `next` action, with the hold as the reason it is waiting; see [pause a lead](/api/reference/campaigns/#pause-a-lead). `cc` lists the contacts copied on every email to the contact in that campaign, empty when none; see [get a lead's CC](/api/reference/campaigns/#get-a-leads-cc). Each step carries whichever of `sent_at`, `opened_at`, `clicked_at`, `replied_at`, `bounced_at` and `failed_at` apply, plus `attempts` and `in_flight` (reserved for a worker whose result has not come back). `opened_at` is a person's open, as it is in the Leads view: a step a mail client prefetched or a security gateway scanned carries no `opened_at`. While a branch condition is undecided, `next.step_id` is absent and `next.step_label` says the step depends on the contact's response.
## List a contact's activities
+24 -1
View File
@@ -162,7 +162,7 @@ A lead can also be taken out again: the remove button on a lead's row, or **Remo
| Replied | Contact replied. With stop on reply on, the cold sequence stops (a reply branch still runs); with it off, the follow-ups continue |
| Bounced | A send hard-bounced |
| Failed | A step could not be sent after every retry; hover the status for the reason |
| Paused | The lead's flow is held, by an out-of-office auto-reply or by hand; hover the status for why and until when |
| Paused | The lead's flow is held: by an out-of-office auto-reply, by hand, or because the contact is [copied on another lead's emails](#copying-colleagues-on-one-lead). Hover the status for why and until when |
| Unsubscribed | Unsubscribed or suppressed |
A lead is **Processing** only while steps remain, so a finished campaign reads as done rather than stuck mid-flight.
@@ -183,6 +183,29 @@ An away message is recognised by the subject line the recipient's mail provider
A held lead reads **Paused** in the list and carries its reason on hover. Open the contact's **Activity** tab and the campaign panel shows the hold with **Resume now** next to it, and **Stop**, which converts a dated hold into one with no end. A lead with a step still to send gets **Pause lead** there instead; one whose flow has ended has neither, because a pause would change nothing. Resume drops the held time rather than carrying it, so the step goes back to the schedule it would have had if the hold had never happened: on the campaign's next pass when that moment has already passed, otherwise when the step's own wait elapses. It lifts the hold, it does not skip the sequence's pacing. Pausing and resuming are audited, and every teammate's list updates live.
### Copying colleagues on one lead
Reaching two people at one company as two separate leads gives each their own thread and their own follow-ups, neither aware of the other, which reads as double outreach. Copy the second person on the first lead's emails instead. Open the lead's contact, go to **Activity**, and use **CC a colleague** on that campaign's card. The picker offers the lead's likely colleagues first (contacts with the same company name, or the same email domain when it is a company's rather than a personal mail service like Gmail or GMX), and searches the whole contact list as you type. A copy is always a contact in the workspace, so suppression, bounces and verification apply to it exactly as to a lead.
Every email the campaign sends that lead carries the copies in **CC**, follow-ups included, so the whole conversation stays in one thread. It is per campaign on purpose: copying someone on a lead in one campaign does not copy them anywhere else. A lead can copy at most `2` contacts. Every copy is one more recipient who did not ask for the email, so this suits small, personal campaigns better than volume sending. It does not change how much the mailbox sends: the email still counts once against its daily cap and spacing, though Google and Microsoft count each copy toward the mailbox's own recipient limits. Leads with copies show a **CC** badge in the Leads list, with the addresses on hover.
What happens to the people on the thread:
| Event | What Warmbly does |
|-------|-------------------|
| A copy replies | It counts as the lead's reply, whether they answer your email or the lead's reply to it. A new email from them that answers nothing counts for no lead. With stop on reply on, the sequence stops for everyone on the thread. A reply that leaves your mailbox off (sent only to the lead) never reaches Warmbly |
| A copy is out of office | Nothing: the lead is not held for someone else's away message |
| A copy replies asking to stop | Only the copy is suppressed and left off later emails. The lead is not |
| Someone uses the unsubscribe link | The link cannot tell who clicked, so the lead and every copy on the thread are unsubscribed |
| A copy bounces | That copy is dropped from the lead's later emails and the lead keeps receiving them. The lead is never marked bounced for a copy. When the mail server refuses the copy outright, the email is retried without them and the refusal does not count against the lead's five attempts |
| A copy is unsubscribed, suppressed, bounced or fails verification | They are left off the next email and their chip is struck through in the drawer; the lead's email still goes out |
Opens and clicks are counted per email, not per person, so a copy opening the email or clicking a link counts toward the lead.
A contact copied on a lead is reached in that lead's thread, so if they are also a lead of the same campaign their own sequence is held with the reason **Copied on the emails to** the lead's address. That hold has no end and does not keep the campaign from finishing. Removing the copy, or removing the lead from the campaign, releases it and their own sequence picks up. **Send their own too** on that hold starts it anyway after asking, for when two threads are really what you want. For the same reason a lead that is copied on someone else cannot copy others, and a contact with copies of their own cannot be copied.
The campaign's own **CC** and **BCC** in its preferences go on every email to every lead. Both kinds of copy skip an address that is suppressed or is the lead's own, so a copy never reaches someone the lead's email could not have. A campaign-wide address that bounces on one of the campaign's emails is left off its later emails, so one bad address cannot fail every lead's send; fix or remove it in the preferences.
### Who opened, clicked and replied
Next to each lead's status, the Leads list shows three engagement columns: **Opened**, **Clicked** and **Replied**, each with the number of emails in the sequence the person engaged with. A dash means the lead was emailed and has not engaged; the cell is blank for a lead not emailed yet. An open counts when a person opened the email, or clicked a link in it. Mail clients that fetch every image automatically (Apple Mail Privacy Protection, for example) show as **auto** instead, the same opens the campaign overview reports as automatic, so they never pass for engagement. Clicks are held to the same standard: a link followed from a known mail security network, within the instance's automated-click window after the send (thirty seconds by default), or several links of one email followed within a few seconds of each other, is a security gateway scanning the message, not the recipient. Those clicks are kept on the contact's activity marked **auto**, counted apart on the campaign overview, and never make a lead **Clicked**, never fire a clicked branch or automation, and never send a webhook. See [Link tracking and UTM parameters](#link-tracking-and-utm-parameters).
+2 -1
View File
@@ -16,6 +16,7 @@ The columns are yours to size. Every choice here is remembered in the browser yo
| The divider between the list and the thread | Drag it to widen the conversation list so long subjects fit, or to give the space back to the thread. It also takes the keyboard once it has focus: arrow keys nudge it (hold `Shift` for a bigger step), `Home` and `End` go to the narrowest and widest the window allows, `Enter` (or a double-click) puts it back to the default. |
| **Collapse** at the bottom of the left navigation, or `b` | Shrinks Warmbly's own navigation to an icon rail and gives the width to the page. Row labels become tooltips; unread mail keeps its count badge and an open [Advisor](/guides/advisor/) finding shows as a coloured dot on the icon. The same control expands it again. |
| The section headers in the left navigation (Email, CRM, Resources) | Click one to fold its section away, and again to bring it back. Like the other layout choices, it is remembered in this browser. A folded section still shows the page you are on, and a coloured dot beside its header flags an open [Advisor](/guides/advisor/) finding on one of the pages it hides. The icon rail follows the same choice. |
| The section headers in the scope rail (Mail, Views, Mailboxes, Labels, Tags) | Click one to fold its section away, and again to bring it back. A folded section still shows the scope you are looking at, and a small blue dot beside its header means a row it folds away has a highlighted count (rows you hid never raise it). The **…** beside a header (or a right-click on it) folds every other section, unfolds them all, or moves the section up or down the rail. The pencil on **Mail** and **Views** puts the rows in edit mode: untick a row to hide it, drag it by its handle (or focus the handle and use the arrow keys) to reorder, and press **Done**, `Escape`, or click anywhere else. **Reset** puts that section back the way it shipped. Each row also has its own **…** menu and right-click menu to move it, hide it (with an undo), or mark a folder read, and `Alt` with an arrow key moves the focused row. A small "2 hidden" beside the pencil tells you rows are off the rail. Hiding a row only takes it off the rail, so its shortcuts and links keep working, and the scope you are in always stays visible. All of it is remembered in this browser. |
| The contact button in the thread header | Shows or hides the contact panel on the right. It starts closed when no preference is saved. Opening or closing it sticks across conversations, and existing preferences are preserved. |
How wide the list can get depends on the window: the thread always keeps enough room to read a message, and the contact panel counts toward that when it is open, so the widest setting is narrower on a laptop than on a large monitor.
@@ -194,7 +195,7 @@ The composer shows the message it will forward under your note; expand **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. 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).
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).
The reply composer offers the same thing for the moment you answer. When the recipient still has a follow-up queued, **Pause follow-ups** next to **Schedule** holds them for 3 days, 1 week, 2 weeks, 30 days or until you resume them. When they are a lead of more than one such campaign, the menu lists each one, all ticked, and you untick the ones to leave running (at least one stays ticked). Nothing is paused until the reply is accepted, and a send that fails pauses nothing. A scheduled reply pauses the follow-ups straight away and counts the length from when the reply goes out, so a follow-up cannot overtake it. Undoing or cancelling the reply does not lift the pause, so use **Resume** in the contact panel if you change your mind. A lead already on hold, for example after an away message arrived while you were writing, keeps that hold.
@@ -19,7 +19,7 @@ The data is split into groups. Every export includes **Workspace**; the rest are
|-------|----------|
| Workspace | The organization, members, roles, teams, mailboxes and their profile photos, mailbox tags, the column mappings saved by [mailbox imports](/guides/mailbox-import/), inbox vendor connections, [root redirects](/guides/sending-domains/#root-redirects), API keys, webhooks, and settings, including the website tracking site key. Always included |
| Contacts | Contacts, categories, the column mappings saved by [contact imports](/guides/contacts-crm/#importing), segments with their manual overrides, forms with their images, submissions, personalized link tickets and funnel events, notes, activities, and the suppression list |
| Campaigns | Campaigns, folders, sequences, senders, linked segments, attachments, the email image library, per-campaign settings, each lead's step progress with its per-link clicks and per-event opens, [placement monitors](/guides/placement-tests/#campaign-placement-monitors), and the [unsubscribe links](/guides/unsubscribe/) already in recipients' inboxes |
| Campaigns | Campaigns, folders, sequences, senders, linked segments, attachments, the email image library, per-campaign settings, each lead's step progress with its per-link clicks and per-event opens, the colleagues [copied on each lead](/guides/campaigns/#copying-colleagues-on-one-lead), [placement monitors](/guides/placement-tests/#campaign-placement-monitors), and the [unsubscribe links](/guides/unsubscribe/) already in recipients' inboxes |
| CRM | Pipelines, deals, tasks, and meeting bookings |
| Automations | Automations, connected integrations, and lead sync sources |
| Assistant | Assistant sessions and messages, skills, MCP servers, and AI settings |
+434 -2
View File
@@ -5823,6 +5823,295 @@
}
}
},
"/campaigns/{id}/leads/{contactId}/cc": {
"get": {
"operationId": "campaigns_get_lead_cc",
"summary": "Get a lead's CC",
"description": "List the contacts copied on every email this campaign sends one lead. Scope READ_CAMPAIGNS and READ_CONTACTS, org permission view_campaigns and view_contacts.",
"tags": [
"campaigns"
],
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "contactId",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "OK.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CampaignLeadCCResult"
}
}
}
},
"401": {
"description": "Unauthorized.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "The campaign is not the caller's organization's, or the contact is not a lead of it.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Rate limited.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"put": {
"operationId": "campaigns_set_lead_cc",
"summary": "Set a lead's CC",
"description": "Replace the contacts copied on every email this campaign sends one lead, follow-ups included. An empty list removes them all. A copied contact who is also a lead of the campaign has their own sequence held (source cc) while any lead copies them. The body is the whole list, so retries are safe without an Idempotency-Key. Scope WRITE_CAMPAIGNS and READ_CONTACTS, org permission manage_campaigns and view_contacts.",
"tags": [
"campaigns"
],
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "contactId",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CampaignLeadCCRequest"
}
}
}
},
"responses": {
"200": {
"description": "OK.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CampaignLeadCCResult"
}
}
}
},
"400": {
"description": "bad_request: a contact_ids entry is not a uuid; lead_cc_limit: more than 2 contacts; lead_cc_self: the lead is in its own list.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "not_found: the campaign is not the caller's organization's, or the contact is not a lead of it; lead_cc_contact_not_found: a contact to copy is not in the workspace.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "lead_cc_lead_is_copied: the lead is copied on another lead in this campaign; lead_cc_has_copies: a contact to copy has copies of their own in this campaign.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Rate limited.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/campaigns/{id}/leads/{contactId}/cc/suggestions": {
"get": {
"operationId": "campaigns_suggest_lead_cc",
"summary": "Suggest colleagues to CC",
"description": "Up to eight contacts who look like the lead's colleagues: the same company name, or the same email domain when it belongs to a company rather than a personal mail service. Unsubscribed contacts and ones already copied are left out. Scope READ_CAMPAIGNS and READ_CONTACTS, org permission view_campaigns and view_contacts.",
"tags": [
"campaigns"
],
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "contactId",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "OK.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CampaignLeadCCSuggestions"
}
}
}
},
"401": {
"description": "Unauthorized.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "The campaign is not the caller's organization's.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Rate limited.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/campaigns/{id}/logs": {
"get": {
"operationId": "campaigns_list_logs",
@@ -30866,6 +31155,13 @@
],
"nullable": true,
"description": "The live per-lead hold: an out-of-office auto-reply parked the contact, or a member paused them. Absent when the lead is not held."
},
"cc": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CampaignLeadCC"
},
"description": "Contacts copied on every email to this lead in this campaign. Absent when none."
}
}
},
@@ -39654,9 +39950,11 @@
"type": "string",
"enum": [
"manual",
"out_of_office"
"out_of_office",
"inbox_tagging",
"cc"
],
"description": "What wrote it."
"description": "What wrote it. cc holds a contact's own lead while they are copied on another lead's emails in the campaign; its reason is that lead's address."
}
},
"required": [
@@ -39706,6 +40004,140 @@
}
}
},
"CampaignLeadCC": {
"type": "object",
"description": "A contact copied on every email one campaign sends one lead.",
"properties": {
"contact_id": {
"type": "string",
"format": "uuid"
},
"email": {
"type": "string",
"format": "email"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"company": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"active",
"unsubscribed",
"bounced",
"undeliverable"
],
"description": "Whether the next email copies them: only active does."
},
"bounced_at": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "Set when a bounce was attributed to this copy on this lead's thread."
}
},
"required": [
"contact_id",
"email",
"first_name",
"last_name",
"status"
]
},
"CampaignLeadCCResult": {
"type": "object",
"properties": {
"campaign_id": {
"type": "string",
"format": "uuid"
},
"contact_id": {
"type": "string",
"format": "uuid"
},
"cc": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CampaignLeadCC"
}
}
},
"required": [
"campaign_id",
"contact_id",
"cc"
]
},
"CampaignLeadCCRequest": {
"type": "object",
"properties": {
"contact_ids": {
"type": "array",
"maxItems": 2,
"items": {
"type": "string",
"format": "uuid"
},
"description": "Contacts of the workspace to copy, at most 2. Duplicates are ignored; an empty list removes every copy."
}
},
"required": [
"contact_ids"
]
},
"CampaignLeadCCSuggestions": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"contact_id": {
"type": "string",
"format": "uuid"
},
"email": {
"type": "string",
"format": "email"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"company": {
"type": "string"
},
"reason": {
"type": "string",
"enum": [
"company",
"domain"
]
}
},
"required": [
"contact_id",
"email",
"first_name",
"last_name",
"reason"
]
}
}
},
"required": [
"data"
]
},
"MailboxSendAsIdentity": {
"type": "object",
"properties": {
+85
View File
@@ -0,0 +1,85 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// leadCCResponse is what the read and the write answer with.
type leadCCResponse struct {
CampaignID string `json:"campaign_id"`
ContactID string `json:"contact_id"`
CC []models.CampaignLeadCC `json:"cc"`
}
func leadCCOK(c *gin.Context, campaignID, contactID uuid.UUID, cc []models.CampaignLeadCC) {
if cc == nil {
cc = []models.CampaignLeadCC{}
}
c.JSON(http.StatusOK, leadCCResponse{CampaignID: campaignID.String(), ContactID: contactID.String(), CC: cc})
}
// GetCampaignLeadCC lists the contacts copied on every email to one lead.
//
// GET /campaigns/:id/leads/:contactId/cc
func (h *Handler) GetCampaignLeadCC(c *gin.Context) {
orgID, campaignID, contactID, ok := leadHoldParams(c)
if !ok {
return
}
cc, xerr := h.CampaignService.ListLeadCC(c.Request.Context(), orgID, campaignID, contactID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
leadCCOK(c, campaignID, contactID, cc)
}
// SetCampaignLeadCC replaces the contacts copied on one lead.
//
// No Idempotency-Key: the body is the whole list, so a retry lands on the
// same state.
//
// PUT /campaigns/:id/leads/:contactId/cc
func (h *Handler) SetCampaignLeadCC(c *gin.Context) {
orgID, campaignID, contactID, ok := leadHoldParams(c)
if !ok {
return
}
var req models.SetCampaignLeadCC
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.InvalidBody(err))
return
}
cc, xerr := h.CampaignService.SetLeadCC(c.Request.Context(), orgID, campaignID, contactID, req.ContactIDs)
if xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityCampaignLead, &contactID, nil, map[string]string{
"campaign_id": campaignID.String(),
"cc": strconv.Itoa(len(cc)),
})
leadCCOK(c, campaignID, contactID, cc)
}
// SuggestCampaignLeadCC offers the lead's likely colleagues to copy.
//
// GET /campaigns/:id/leads/:contactId/cc/suggestions
func (h *Handler) SuggestCampaignLeadCC(c *gin.Context) {
orgID, campaignID, contactID, ok := leadHoldParams(c)
if !ok {
return
}
out, xerr := h.CampaignService.SuggestLeadCC(c.Request.Context(), orgID, campaignID, contactID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
+8
View File
@@ -704,6 +704,14 @@ func Run(
campaigns.POST("/:id/leads/:contactId/pause", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.PauseCampaignLead)
campaigns.POST("/:id/leads/:contactId/resume", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.ResumeCampaignLead)
// Contacts copied on every email to one lead, so colleagues
// share one thread. The answers carry contact details, hence
// the contacts gate too. PUT replaces the list, so retries are
// safe.
campaigns.GET("/:id/leads/:contactId/cc", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetCampaignLeadCC)
campaigns.PUT("/:id/leads/:contactId/cc", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.SetCampaignLeadCC)
campaigns.GET("/:id/leads/:contactId/cc/suggestions", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.SuggestCampaignLeadCC)
sequences := campaigns.Group("/:id/steps")
{
sequences.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetSequences)
+36 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/mailhdr"
)
// RecordInboundBounce turns a permanent NDR the worker parsed into a bounce
@@ -60,9 +61,19 @@ func (s *service) RecordInboundBounce(ctx context.Context, emailAccountID uuid.U
if ct, cerr := s.taskRepo.GetCampaignTask(ctx, task.ID); cerr == nil && ct != nil {
req.CampaignID = ct.CampaignID
req.ContactID = ct.ContactID
if req.RecipientEmail == "" && ct.ContactID != nil {
if ct.ContactID != nil {
if contact, cerr := s.contactRepo.GetByID(ctx, *ct.ContactID); cerr == nil && contact != nil {
req.RecipientEmail = contact.Email
switch {
case req.RecipientEmail == "":
req.RecipientEmail = contact.Email
case ct.CampaignID != nil && !strings.EqualFold(mailhdr.Bare(req.RecipientEmail), strings.TrimSpace(contact.Email)):
if owner, isCopy := s.copyBounceOwner(ctx, *ct.CampaignID, *ct.ContactID, req.RecipientEmail); isCopy {
// A copy bounced, not the lead: the lead keeps its
// sequence, and the copy's own NDR is its own event.
req.ContactID = owner
req.IdempotencyKey += ":" + strings.ToLower(mailhdr.Bare(req.RecipientEmail))
}
}
}
}
}
@@ -144,3 +155,26 @@ func (s *service) RecordInboundComplaint(ctx context.Context, emailAccountID uui
return s.IngestDeliverabilityEvent(ctx, *account.OrganizationID, req)
}
// copyBounceOwner tells a bounced copy apart from the lead. A contact copied on
// the lead is marked bounced there and owns the event; an address from the
// campaign's own CC or BCC owns it with no contact. Any other address (a
// forward, an alias) stays the lead's, as before.
func (s *service) copyBounceOwner(ctx context.Context, campaignID, leadID uuid.UUID, address string) (*uuid.UUID, bool) {
bare := mailhdr.Bare(address)
if s.campaignProgressRepo != nil {
if id, err := s.campaignProgressRepo.MarkLeadCCBounced(ctx, campaignID, leadID, bare); err == nil && id != nil {
return id, true
}
}
if campaign, err := s.campaignRepo.GetByID(ctx, campaignID); err == nil && campaign != nil {
for _, list := range [][]string{campaign.CC, campaign.BCC} {
for _, a := range list {
if strings.EqualFold(mailhdr.Bare(a), bare) {
return nil, true
}
}
}
}
return &leadID, false
}
@@ -83,3 +83,49 @@ func TestRecordInboundBounceStillAttributesRealSends(t *testing.T) {
t.Fatal("a campaign NDR was dropped by the warmup gate")
}
}
type copyBounceProgress struct {
repository.CampaignProgressRepository
copies map[string]uuid.UUID
}
func (r copyBounceProgress) MarkLeadCCBounced(_ context.Context, _, _ uuid.UUID, address string) (*uuid.UUID, error) {
if id, ok := r.copies[address]; ok {
return &id, nil
}
return nil, nil
}
type copyBounceCampaigns struct {
repository.CampaignRepository
cc, bcc []string
}
func (r copyBounceCampaigns) GetByID(_ context.Context, id uuid.UUID) (*models.Campaign, error) {
return &models.Campaign{ID: id, CC: r.cc, BCC: r.bcc}, nil
}
// A DSN naming someone copied on the send bounces that copy, not the lead; an
// address nobody copied (a forward, an alias) stays the lead's as before.
func TestCopyBounceOwnerTellsACopyFromTheLead(t *testing.T) {
lead, copied := uuid.New(), uuid.New()
s := &service{
campaignProgressRepo: copyBounceProgress{copies: map[string]uuid.UUID{"jonas@acme.test": copied}},
campaignRepo: copyBounceCampaigns{cc: []string{"Boss <boss@acme.test>"}, bcc: []string{"crm@acme.test"}},
}
for _, tc := range []struct {
address string
owner *uuid.UUID
isCopy bool
}{
{"jonas@acme.test", &copied, true},
{"BOSS@acme.test", nil, true},
{"crm@acme.test", nil, true},
{"forwarded@elsewhere.test", &lead, false},
} {
owner, isCopy := s.copyBounceOwner(context.Background(), uuid.New(), lead, tc.address)
if isCopy != tc.isCopy || (owner == nil) != (tc.owner == nil) || (owner != nil && *owner != *tc.owner) {
t.Errorf("%s: owner %v copy %v, want %v %v", tc.address, owner, isCopy, tc.owner, tc.isCopy)
}
}
}
@@ -84,6 +84,17 @@ type incomingReplyProgressRepo struct {
receivingSent bool
completeErr error
advanced *incomingReplyAdvancedRepo
copies []models.CampaignLeadCC
copiedLead *repository.CopiedLeadRef
copiesErr error
}
func (r *incomingReplyProgressRepo) ListLeadCC(context.Context, uuid.UUID, uuid.UUID) ([]models.CampaignLeadCC, error) {
return r.copies, r.copiesErr
}
func (r *incomingReplyProgressRepo) LeadForCopiedReply(context.Context, uuid.UUID, uuid.UUID) (*repository.CopiedLeadRef, error) {
return r.copiedLead, nil
}
func (r *incomingReplyProgressRepo) IsInboundReplySource(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
@@ -0,0 +1,185 @@
package advanced
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type copyReplyAdvancedRepo struct {
*incomingReplyAdvancedRepo
suppressed []string
}
func (r *copyReplyAdvancedRepo) UpsertSuppressedRecipient(_ context.Context, s *models.SuppressedRecipient) error {
r.suppressed = append(r.suppressed, strings.ToLower(s.Email))
return nil
}
type copyReplyContactRepo struct {
incomingReplyContactRepo
}
func (copyReplyContactRepo) SetSubscribedByEmail(context.Context, uuid.UUID, string, bool) error {
return nil
}
type copyReplyProgressRepo struct {
*incomingReplyProgressRepo
heldEverywhere int
}
func (r *copyReplyProgressRepo) HoldLeadEverywhere(context.Context, uuid.UUID, *time.Time, string, string) ([]uuid.UUID, error) {
r.heldEverywhere++
return nil, nil
}
// newCopyReplyService is the incoming-reply harness with the lead copying
// jonas@acme.test, answering in the lead's thread.
func newCopyReplyService(t *testing.T) (*service, *copyReplyProgressRepo, *copyReplyAdvancedRepo, uuid.UUID) {
t.Helper()
orgID, accountID, leadID := uuid.New(), uuid.New(), uuid.New()
account := &models.Email{ID: accountID, OrganizationID: &orgID, Email: "sender@example.test"}
svc, progress := newIncomingReplyService(account, nil, leadID)
progress.copies = []models.CampaignLeadCC{{ContactID: uuid.New(), Email: "jonas@acme.test", Status: models.LeadCCStatusActive}}
adv := &copyReplyAdvancedRepo{incomingReplyAdvancedRepo: progress.advanced}
wrapped := &copyReplyProgressRepo{incomingReplyProgressRepo: progress}
svc.repo = adv
svc.campaignProgressRepo = wrapped
svc.contactRepo = copyReplyContactRepo{svc.contactRepo.(incomingReplyContactRepo)}
return svc, wrapped, adv, accountID
}
func copyReply(accountID uuid.UUID, subject, body string, inReplyTo []string) *models.EmailMessageStoreData {
return &models.EmailMessageStoreData{
ID: uuid.New(), EmailID: accountID, Folder: models.FolderInbox,
FromAddr: []string{"Jonas <jonas@acme.test>"},
ToAddr: []string{"sender@example.test"},
InReplyTo: inReplyTo,
Subject: subject,
Snippet: body,
BodyText: body,
}
}
// A copy asking to stop is about the copy: the lead they were copied on is
// not suppressed with them.
func TestCopyOptOutSuppressesOnlyTheCopy(t *testing.T) {
svc, _, adv, accountID := newCopyReplyService(t)
if xerr := svc.ProcessIncomingReply(context.Background(), accountID,
copyReply(accountID, "Re: Hello", "Please remove me from your list.", []string{"<opener@example.test>"})); xerr != nil {
t.Fatal(xerr)
}
if len(adv.suppressed) != 1 || adv.suppressed[0] != "jonas@acme.test" {
t.Fatalf("suppressed %v, want only the copy", adv.suppressed)
}
}
// A copy's away message says nothing about the lead's desk.
func TestCopyOutOfOfficeDoesNotHoldTheLead(t *testing.T) {
svc, progress, _, accountID := newCopyReplyService(t)
if xerr := svc.ProcessIncomingReply(context.Background(), accountID,
copyReply(accountID, "Automatic reply: Hello", "I am out of the office until Monday.", []string{"<opener@example.test>"})); xerr != nil {
t.Fatal(xerr)
}
if progress.heldEverywhere != 0 {
t.Fatalf("the lead was held %d times for a copy's away message", progress.heldEverywhere)
}
}
// A copy answering further down the thread names no message of ours; the
// mailbox that wrote to the lead ties it back, and it is the lead's reply.
func TestCopyReplyDownThreadCountsForTheLead(t *testing.T) {
svc, progress, _, accountID := newCopyReplyService(t)
copyContact := &models.Contact{ID: uuid.New(), Email: "jonas@acme.test"}
svc.taskRepo = incomingReplyTaskRepo{}
cr := svc.contactRepo.(copyReplyContactRepo)
cr.senderContact = copyContact
svc.contactRepo = cr
progress.copiedLead = &repository.CopiedLeadRef{CampaignID: uuid.New(), ContactID: uuid.New(), SequenceID: uuid.New()}
if xerr := svc.ProcessIncomingReply(context.Background(), accountID,
copyReply(accountID, "Re: Hello", "Sounds good, let's talk Tuesday.", []string{"<leads-own-reply@acme.test>"})); xerr != nil {
t.Fatal(xerr)
}
if progress.replied != 1 {
t.Fatalf("RecordEmailReplied calls = %d, want the copy's reply counted for the lead", progress.replied)
}
}
// The link in a copied message cannot say who used it, so it opts out the
// lead and everyone copied; a sequence action is about the lead alone.
func TestUnsubscribeLinkOptsOutTheLeadsCopies(t *testing.T) {
for _, tc := range []struct {
name string
run func(s *service, org, campaign, lead uuid.UUID) *errx.Error
want []string
}{
{"link", func(s *service, org, campaign, lead uuid.UUID) *errx.Error {
return s.UnsubscribeFromLink(context.Background(), org, campaign, lead, "one_click")
}, []string{"task-contact@example.test", "jonas@acme.test"}},
{"sequence action", func(s *service, _, campaign, lead uuid.UUID) *errx.Error {
return s.Unsubscribe(context.Background(), campaign, lead)
}, []string{"task-contact@example.test"}},
} {
svc, _, adv, _ := newCopyReplyService(t)
campaign := svc.campaignRepo.(incomingReplyCampaignRepo).campaign
lead := svc.contactRepo.(copyReplyContactRepo).taskContact.ID
if xerr := tc.run(svc, *campaign.OrganizationID, campaign.ID, lead); xerr != nil {
t.Fatalf("%s: %v", tc.name, xerr)
}
if strings.Join(adv.suppressed, ",") != strings.Join(tc.want, ",") {
t.Fatalf("%s: suppressed %v, want %v", tc.name, adv.suppressed, tc.want)
}
}
}
// An unreadable copy list is an error, never "not a copy": guessing would
// suppress or hold the lead for what a copy did.
func TestCopyLookupFailureIsNotReadAsTheLead(t *testing.T) {
svc, progress, adv, accountID := newCopyReplyService(t)
progress.copiesErr = errors.New("database unavailable")
if xerr := svc.ProcessIncomingReply(context.Background(), accountID,
copyReply(accountID, "Re: Hello", "Please remove me from your list.", []string{"<opener@example.test>"})); xerr == nil {
t.Fatal("a failed copy lookup was processed as if the sender were not a copy")
}
if len(adv.suppressed) != 0 {
t.Fatalf("suppressed %v on a failed lookup", adv.suppressed)
}
}
// A fresh message from a copy is not a reply to anything, so it credits no lead.
func TestCopyFreshMessageCreditsNoLead(t *testing.T) {
svc, progress, _, accountID := newCopyReplyService(t)
svc.taskRepo = incomingReplyTaskRepo{}
cr := svc.contactRepo.(copyReplyContactRepo)
cr.senderContact = &models.Contact{ID: uuid.New(), Email: "jonas@acme.test"}
svc.contactRepo = cr
progress.copiedLead = &repository.CopiedLeadRef{CampaignID: uuid.New(), ContactID: uuid.New(), SequenceID: uuid.New()}
if xerr := svc.ProcessIncomingReply(context.Background(), accountID,
copyReply(accountID, "Quick question", "Unrelated: are you at the fair next week?", nil)); xerr != nil {
t.Fatal(xerr)
}
if progress.replied != 0 {
t.Fatalf("RecordEmailReplied calls = %d, want none for a message that replies to nothing", progress.replied)
}
}
// The opt-out is not acknowledged while a copy is still sendable.
func TestUnsubscribeFailsWhenCopiesCannotBeRead(t *testing.T) {
svc, progress, _, _ := newCopyReplyService(t)
progress.copiesErr = errors.New("database unavailable")
campaign := svc.campaignRepo.(incomingReplyCampaignRepo).campaign
lead := svc.contactRepo.(copyReplyContactRepo).taskContact.ID
if xerr := svc.UnsubscribeFromLink(context.Background(), *campaign.OrganizationID, campaign.ID, lead, "link"); xerr == nil {
t.Fatal("the unsubscribe succeeded without reaching the lead's copies")
}
}
+102 -3
View File
@@ -641,6 +641,72 @@ func (s *service) unsubscribe(ctx context.Context, expectOrg *uuid.UUID, campaig
"contact_email": contact.Email,
"source": via,
})
// The link in a message is the same for everyone it copied and cannot say
// who used it, so it opts all of them out. A sequence action is about the
// lead alone.
if via != "action" {
return s.unsubscribeLeadCopies(ctx, *campaign.OrganizationID, campaignID, contactID, contact.Email, via, reason)
}
return nil
}
// isLeadCopy reports whether sender is one of the contacts copied on the
// lead's emails rather than the lead answering from another address. A failed
// read is an error, never "not a copy", which would charge the lead.
func (s *service) isLeadCopy(ctx context.Context, campaignID, contactID uuid.UUID, leadEmail, sender string) (bool, error) {
if s.campaignProgressRepo == nil || sender == "" || strings.EqualFold(leadEmail, sender) {
return false, nil
}
copies, err := s.campaignProgressRepo.ListLeadCC(ctx, campaignID, contactID)
if err != nil {
return false, err
}
for _, cp := range copies {
if strings.EqualFold(strings.TrimSpace(cp.Email), sender) {
return true, nil
}
}
return false, nil
}
// unsubscribeLeadCopies suppresses every contact copied on one lead's emails.
// A failure fails the request, so the opt-out is retried rather than
// acknowledged with a copy still sendable; the upserts are idempotent.
func (s *service) unsubscribeLeadCopies(ctx context.Context, orgID, campaignID, contactID uuid.UUID, leadEmail, via, reason string) *errx.Error {
if s.campaignProgressRepo == nil {
return nil
}
copies, err := s.campaignProgressRepo.ListLeadCC(ctx, campaignID, contactID)
if err != nil {
return toErrx(err)
}
for _, cp := range copies {
addr := strings.ToLower(strings.TrimSpace(cp.Email))
if addr == "" {
continue
}
if err := s.repo.UpsertSuppressedRecipient(ctx, &models.SuppressedRecipient{
OrganizationID: orgID,
Email: addr,
Kind: models.SuppressionKindEmail,
Reason: reason + " on an email copied to them",
Source: models.DeliverabilityEventUnsubscribe,
CampaignID: &campaignID,
Metadata: map[string]interface{}{"via": via, "copied_on": leadEmail},
}); err != nil {
return toErrx(err)
}
if err := s.contactRepo.SetSubscribedByEmail(ctx, orgID, addr, false); err != nil {
log.Warn().Err(err).Str("contact_id", cp.ContactID.String()).Msg("unsubscribe: could not clear a copied contact's subscription flag")
}
s.emit(ctx, orgID, models.WebhookEventCampaignUnsubscribed, map[string]any{
"campaign_id": campaignID.String(),
"contact_id": cp.ContactID.String(),
"contact_email": cp.Email,
"source": via,
})
}
return nil
}
@@ -1173,6 +1239,10 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
// contactEmail is the address we mailed, which is not always the one
// that answered; an opt-out has to reach both.
var contactEmail string
// senderIsCopy is a reply from a contact copied on the lead's emails. It
// counts as the lead's reply, but the copy's own away message or opt-out
// is about the copy, not the lead.
var senderIsCopy bool
// First, try exact message threading via In-Reply-To.
for _, mid := range msg.InReplyTo {
@@ -1223,6 +1293,11 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
campaignID = ct.CampaignID
contactID = ct.ContactID
sequenceID = ct.SequenceID
isCopy, cerr := s.isLeadCopy(ctx, *ct.CampaignID, *ct.ContactID, contactEmail, sender)
if cerr != nil {
return toErrx(cerr)
}
senderIsCopy = isCopy
break
}
@@ -1245,6 +1320,29 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
}
}
// A copied contact answering further down the thread (to the lead's own
// reply, say) names no message of ours; the mailbox that wrote to the
// lead is the evidence, and the reply is the lead's. A fresh message with
// no parent is not a reply to anything and credits nobody.
if campaignID == nil && contactID != nil && !referencesCampaignThread && len(msg.InReplyTo) > 0 {
ref, err := s.campaignProgressRepo.LeadForCopiedReply(ctx, *contactID, emailAccountID)
if err != nil {
return toErrx(err)
}
if ref != nil {
lead, lerr := s.contactRepo.GetByID(ctx, ref.ContactID)
if lerr != nil {
return lerr
}
if lead != nil {
campaignID, sequenceID = &ref.CampaignID, &ref.SequenceID
contactID = &ref.ContactID
contactEmail = strings.TrimSpace(lead.Email)
senderIsCopy = true
}
}
}
if campaignID != nil && contactID != nil && sequenceID != nil {
cID, ctID, sID := *campaignID, *contactID, *sequenceID
@@ -1416,7 +1514,7 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
}
var held *time.Time
if campaignID != nil && contactID != nil && verdict.Class == replyclassify.ClassOutOfOffice && settings.ReplyIntent.HoldOnOutOfOffice {
if campaignID != nil && contactID != nil && !senderIsCopy && verdict.Class == replyclassify.ClassOutOfOffice && settings.ReplyIntent.HoldOnOutOfOffice {
held = s.holdForOutOfOffice(ctx, *account.OrganizationID, *contactID, settings.ReplyIntent, msg)
}
@@ -1446,8 +1544,9 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
if err := s.contactRepo.SetSubscribedByEmail(ctx, *account.OrganizationID, sender, false); err != nil {
log.Warn().Err(err).Msg("reply opt-out: could not clear the contact's subscription flag")
}
// Answered from another address: the one we mailed asked to stop too.
if contactEmail != "" && !strings.EqualFold(contactEmail, sender) {
// Answered from another address: the one we mailed asked to stop too,
// unless it was a copy asking for themselves.
if contactEmail != "" && !senderIsCopy && !strings.EqualFold(contactEmail, sender) {
_ = s.repo.UpsertSuppressedRecipient(ctx, &models.SuppressedRecipient{
OrganizationID: *account.OrganizationID,
Email: strings.ToLower(contactEmail),
+111
View File
@@ -0,0 +1,111 @@
package campaign
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
// leadCCSuggestionLimit bounds the colleagues offered when picking copies.
const leadCCSuggestionLimit = 8
func (s *campaignService) ListLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID) ([]models.CampaignLeadCC, *errx.Error) {
if s.campaignProgressRepo == nil {
return nil, errx.InternalError()
}
if xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil {
return nil, xerr
}
if _, err := s.campaignProgressRepo.GetLeadHold(ctx, campaignID, contactID); err != nil {
if errors.Is(err, repository.ErrLeadNotInCampaign) {
return nil, errx.New(errx.NotFound, "contact is not a lead of this campaign")
}
return nil, errx.InternalError()
}
cc, err := s.campaignProgressRepo.ListLeadCC(ctx, campaignID, contactID)
if err != nil {
return nil, errx.InternalError()
}
return cc, nil
}
func (s *campaignService) SetLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID, contactIDs []string) ([]models.CampaignLeadCC, *errx.Error) {
if s.campaignProgressRepo == nil {
return nil, errx.InternalError()
}
if xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil {
return nil, xerr
}
ids := make([]uuid.UUID, 0, len(contactIDs))
seen := map[uuid.UUID]bool{}
for _, raw := range contactIDs {
id, err := uuid.Parse(raw)
if err != nil {
return nil, errx.New(errx.BadRequest, "contact_ids must be contact ids")
}
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
if len(ids) > config.CampaignLeadMaxCC {
return nil, errx.NewWithIdentifier(errx.BadRequest, "lead_cc_limit",
fmt.Sprintf("A lead can have at most %d contacts copied on their emails", config.CampaignLeadMaxCC))
}
before, err := s.campaignProgressRepo.ListLeadCC(ctx, campaignID, contactID)
if err != nil {
return nil, errx.InternalError()
}
switch err := s.campaignProgressRepo.SetLeadCC(ctx, orgID, campaignID, contactID, ids); {
case err == nil:
case errors.Is(err, repository.ErrLeadNotInCampaign):
return nil, errx.New(errx.NotFound, "contact is not a lead of this campaign")
case errors.Is(err, repository.ErrLeadCCSelf):
return nil, errx.NewWithIdentifier(errx.BadRequest, "lead_cc_self", "A lead cannot be copied on their own emails")
case errors.Is(err, repository.ErrLeadCCContactNotFound):
return nil, errx.NewWithIdentifier(errx.NotFound, "lead_cc_contact_not_found", "A contact to copy was not found in this workspace")
case errors.Is(err, repository.ErrLeadCCLeadIsCopied):
return nil, errx.NewWithIdentifier(errx.Conflict, "lead_cc_lead_is_copied",
"This lead is copied on another lead's emails in this campaign, so it sends none of its own to copy anyone on")
case errors.Is(err, repository.ErrLeadCCHasCopies):
return nil, errx.NewWithIdentifier(errx.Conflict, "lead_cc_has_copies",
"A contact to copy has contacts copied on their own emails in this campaign; remove those first")
default:
return nil, errx.InternalError()
}
// A removed copy's own lead is released and may be due now.
for _, b := range before {
if !seen[b.ContactID] {
s.WakeCampaigns(ctx, orgID, []string{campaignID.String()})
break
}
}
cc, err := s.campaignProgressRepo.ListLeadCC(ctx, campaignID, contactID)
if err != nil {
return nil, errx.InternalError()
}
return cc, nil
}
func (s *campaignService) SuggestLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID) ([]models.CampaignLeadCCSuggestion, *errx.Error) {
if s.campaignProgressRepo == nil {
return nil, errx.InternalError()
}
if xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil {
return nil, xerr
}
out, err := s.campaignProgressRepo.SuggestLeadCC(ctx, orgID, campaignID, contactID, leadCCSuggestionLimit)
if err != nil {
return nil, errx.InternalError()
}
return out, nil
}
+8
View File
@@ -89,6 +89,14 @@ type CampaignService interface {
ResumeLead(ctx context.Context, orgID, campaignID, contactID uuid.UUID) *errx.Error
// GetLeadHold reads the live hold on one lead (nil when it is not held).
GetLeadHold(ctx context.Context, orgID, campaignID, contactID uuid.UUID) (*models.LeadHold, *errx.Error)
// ListLeadCC reads the contacts copied on every email to one lead.
ListLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID) ([]models.CampaignLeadCC, *errx.Error)
// SetLeadCC replaces the contacts copied on one lead and returns the new
// list. A copied contact's own lead in the campaign is held meanwhile.
SetLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID, contactIDs []string) ([]models.CampaignLeadCC, *errx.Error)
// SuggestLeadCC offers the lead's likely colleagues to copy.
SuggestLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID) ([]models.CampaignLeadCCSuggestion, *errx.Error)
}
// Bounds on a manual lead hold. A hold in the past would lift the moment it
+65 -5
View File
@@ -15,6 +15,7 @@ import (
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/mailhdr"
"github.com/warmbly/warmbly/internal/repository"
)
@@ -167,7 +168,7 @@ func (s *JobsService) HandleEmailFailed(ctx context.Context, result models.SendE
switch task.TaskType {
case "campaign":
return s.failCampaignSend(ctx, task, reason, code, nil)
return s.failCampaignSend(ctx, task, reason, code, refusedRecipient(result), nil)
case "email":
s.notifyUserSendFailed(ctx, task, reason)
case "placement":
@@ -194,7 +195,7 @@ func (s *JobsService) failWarmupSend(ctx context.Context, task *repository.Task,
// day the send was counted against, for giving the daily counters back; nil
// reads it off the task, which is right for a worker result but not for the
// reclaimer, whose sends can be counted on an earlier day.
func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Task, reason, code string, countedOn *time.Time) error {
func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Task, reason, code, refused string, countedOn *time.Time) error {
ct, err := s.TaskRepo.GetCampaignTask(ctx, task.ID)
if err != nil {
return err
@@ -216,6 +217,11 @@ func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Tas
}
}
// A copy the server refused is not the lead's bounce: it is dropped from
// later emails and the lead's step is retried without it.
copyRefused := code == string(errx.MailErrorCodeRecipientRejected) && refused != "" &&
recipient != "" && !strings.EqualFold(mailhdr.Bare(refused), recipient)
// A refusal on the SENDING DOMAIN's authentication is not about this lead:
// the recipient received nothing, and every retry from that domain fails
// identically until its DNS is fixed. Give the reservation back without
@@ -234,14 +240,22 @@ func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Tas
}
// Only permanent recipient refusals are bounce evidence; deferrals may use the same wording.
if s.Evidence != nil && ct.ContactID != nil && ct.SequenceID != nil &&
if s.Evidence != nil && ct.ContactID != nil && ct.SequenceID != nil && !copyRefused &&
code != string(errx.MailErrorCodeServerUnreachable) && emailverify.NamesRecipient(reason) {
s.Evidence.RecordEvidence(ctx, *ct.ContactID, models.Step(&campaignID, ct.SequenceID), "bounced_recipient", "send:"+ct.SequenceID.String(), reason)
}
// A refused copy that the retry will leave off costs the lead no attempt;
// one that could not be recorded is counted, so it cannot loop forever.
copyExcluded := copyRefused && s.recordRefusedCopy(ctx, task, ct, campaign, refused, reason)
attempts, exhausted, rolledBack := 0, false, false
if ct.ContactID != nil && ct.SequenceID != nil && s.CampaignProgressRepo != nil {
attempts, exhausted, rolledBack, err = s.CampaignProgressRepo.RecordSendFailure(ctx, campaignID, *ct.ContactID, *ct.SequenceID, reason)
if copyExcluded {
attempts, exhausted, rolledBack, err = s.CampaignProgressRepo.WalkBackSend(ctx, campaignID, *ct.ContactID, *ct.SequenceID, reason, false)
} else {
attempts, exhausted, rolledBack, err = s.CampaignProgressRepo.RecordSendFailure(ctx, campaignID, *ct.ContactID, *ct.SequenceID, reason)
}
if err != nil {
return err
}
@@ -271,7 +285,7 @@ func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Tas
// reputation, so it goes through the bounce pipeline (progress, optional
// suppression, guardrails, warmup health, webhooks) and the lead is
// dropped as bounced instead of being offered again.
if rolledBack && code == string(errx.MailErrorCodeRecipientRejected) {
if rolledBack && !copyRefused && code == string(errx.MailErrorCodeRecipientRejected) {
if s.recordSynchronousBounce(ctx, task, ct, campaign, recipient, reason) {
s.logCampaignSendFailure(ctx, campaignID, ct, recipient, reason, code, attempts, false, false, false)
s.publishCampaignUpdated(ctx, campaign, campaignID, "")
@@ -335,6 +349,52 @@ func (s *JobsService) recordSynchronousBounce(ctx context.Context, task *reposit
return true
}
// recordRefusedCopy feeds a copied address the server refused at RCPT into
// the bounce pipeline under its own name, and reports whether the next send
// is sure to leave it off: a lead's copy through its bounced mark, a
// campaign-wide one through the recorded bounce the send path reads.
func (s *JobsService) recordRefusedCopy(ctx context.Context, task *repository.Task, ct *repository.CampaignTask, campaign *models.Campaign, refused, reason string) bool {
if campaign == nil || campaign.OrganizationID == nil || ct.CampaignID == nil || ct.ContactID == nil {
return false
}
address := strings.ToLower(mailhdr.Bare(refused))
var owner *uuid.UUID
if s.CampaignProgressRepo != nil {
id, err := s.CampaignProgressRepo.MarkLeadCCBounced(ctx, *ct.CampaignID, *ct.ContactID, address)
if err != nil {
log.Warn().Err(err).Str("task_id", task.ID.String()).Msg("could not mark a refused copy bounced")
}
owner = id
}
if s.AdvancedService == nil {
return owner != nil
}
taskID := task.ID
req := &models.IngestDeliverabilityEventRequest{
EventType: models.DeliverabilityEventBounce,
Provider: "smtp_reject",
TaskID: &taskID,
CampaignID: ct.CampaignID,
ContactID: owner,
RecipientEmail: address,
Reason: reason,
IdempotencyKey: "reject:" + taskID.String() + ":" + address,
}
if xerr := s.AdvancedService.IngestDeliverabilityEvent(ctx, *campaign.OrganizationID, req); xerr != nil {
log.Warn().Str("task_id", taskID.String()).Str("error", xerr.Message).Msg("could not record a refused copy as a bounce")
return owner != nil
}
return true
}
// refusedRecipient is the address the server refused, when the worker knew.
func refusedRecipient(result models.SendEmailResult) string {
if result.Error == nil {
return ""
}
return result.Error.Recipient
}
// publishCampaignUpdated pulses the campaign for every teammate (status "" keeps
// the dashboard's status as is).
func (s *JobsService) publishCampaignUpdated(ctx context.Context, campaign *models.Campaign, campaignID uuid.UUID, status string) {
+138
View File
@@ -0,0 +1,138 @@
package jobs
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/advanced"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type copyContactRepo struct {
repository.ContactRepository
email string
}
func (r copyContactRepo) GetByID(_ context.Context, id uuid.UUID) (*models.Contact, *errx.Error) {
return &models.Contact{ID: id, Email: r.email}, nil
}
type copyCampaignRepo struct {
repository.CampaignRepository
org uuid.UUID
}
func (r copyCampaignRepo) GetByID(_ context.Context, id uuid.UUID) (*models.Campaign, error) {
return &models.Campaign{ID: id, OrganizationID: &r.org, Status: "active"}, nil
}
func (copyCampaignRepo) DecrementCampaignDailySend(context.Context, uuid.UUID, time.Time, bool) error {
return nil
}
type copyProgressRepo struct {
repository.CampaignProgressRepository
copyID uuid.UUID
bounced []string
counted []bool
}
func (r *copyProgressRepo) RecordSendFailure(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, string) (int, bool, bool, error) {
r.counted = append(r.counted, true)
return 1, false, true, nil
}
func (r *copyProgressRepo) WalkBackSend(_ context.Context, _, _, _ uuid.UUID, _ string, count bool) (int, bool, bool, error) {
r.counted = append(r.counted, count)
return 0, false, true, nil
}
func (*copyProgressRepo) HasSentSteps(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
return false, nil
}
func (r *copyProgressRepo) MarkLeadCCBounced(_ context.Context, _, _ uuid.UUID, address string) (*uuid.UUID, error) {
r.bounced = append(r.bounced, address)
return &r.copyID, nil
}
type copyAdvanced struct {
advanced.Service
events []*models.IngestDeliverabilityEventRequest
}
func (a *copyAdvanced) IngestDeliverabilityEvent(_ context.Context, _ uuid.UUID, req *models.IngestDeliverabilityEventRequest) *errx.Error {
a.events = append(a.events, req)
return nil
}
// A copy the server refused at RCPT bounces the copy, never the lead: no
// address evidence against the lead, and the bounce event names the copy.
func TestRefusedCopyIsNotTheLeadsBounce(t *testing.T) {
for _, tc := range []struct {
name string
refused string
wantCopy bool
}{
{"a copy", "Jonas <jonas@acme.test>", true},
{"the lead", "ana@acme.test", false},
} {
campaign, lead, step, taskID := uuid.New(), uuid.New(), uuid.New(), uuid.New()
progress := &copyProgressRepo{copyID: uuid.New()}
adv := &copyAdvanced{}
ev := &recordingEvidence{}
s := &JobsService{
TaskRepo: &evidenceTaskRepo{
task: &repository.Task{ID: taskID, TaskType: "campaign", EmailAccountID: uuid.New(), Status: "completed"},
ct: &repository.CampaignTask{TaskID: taskID, CampaignID: &campaign, ContactID: &lead, SequenceID: &step},
},
CampaignRepo: copyCampaignRepo{org: uuid.New()},
CampaignProgressRepo: progress,
ContactRepo: copyContactRepo{email: "ana@acme.test"},
AdvancedService: adv,
Evidence: ev,
}
err := s.HandleEmailFailed(context.Background(), models.SendEmailResult{
TaskID: taskID,
Error: &models.EmailSendError{
Code: string(errx.MailErrorCodeRecipientRejected),
Message: `The mail server rejected the recipient: 550 "5.1.1 no such user"`,
Recipient: tc.refused,
},
})
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if len(adv.events) != 1 {
t.Fatalf("%s: %d bounce events, want 1", tc.name, len(adv.events))
}
got := adv.events[0]
if tc.wantCopy {
if len(ev.kinds) != 0 {
t.Fatalf("%s: evidence %v recorded against the lead", tc.name, ev.kinds)
}
if got.RecipientEmail != "jonas@acme.test" || got.ContactID == nil || *got.ContactID != progress.copyID {
t.Fatalf("%s: bounce event %+v, want it on the copy", tc.name, got)
}
if len(progress.bounced) != 1 {
t.Fatalf("%s: copy marked bounced %v times, want once", tc.name, progress.bounced)
}
// The retry leaves the copy off, so the lead is not charged an attempt.
if len(progress.counted) != 1 || progress.counted[0] {
t.Fatalf("%s: walk-backs %v, want one that counts no attempt", tc.name, progress.counted)
}
continue
}
if len(progress.counted) != 1 || !progress.counted[0] {
t.Fatalf("%s: walk-backs %v, want the lead's attempt counted", tc.name, progress.counted)
}
if got.RecipientEmail != "ana@acme.test" || got.ContactID == nil || *got.ContactID != lead || len(progress.bounced) != 0 {
t.Fatalf("%s: bounce event %+v (copies marked %v), want the lead's own bounce", tc.name, got, progress.bounced)
}
}
}
@@ -109,7 +109,7 @@ func (s *JobsService) reclaimStuckSend(ctx context.Context, d repository.StuckDi
return "", err
}
dispatchedAt := d.DispatchedAt
if err := s.failCampaignSend(ctx, task, reason, "SEND_OUTCOME_LOST", &dispatchedAt); err != nil {
if err := s.failCampaignSend(ctx, task, reason, "SEND_OUTCOME_LOST", "", &dispatchedAt); err != nil {
return "", err
}
return "reclaimed", nil
+8 -1
View File
@@ -114,8 +114,15 @@ func holdCopy(hold *models.LeadHold) string {
return "Paused for this contact"
}
what := "Paused for this contact"
if hold.Source == models.LeadHoldSourceOutOfOffice {
switch hold.Source {
case models.LeadHoldSourceOutOfOffice:
what = "Out of office"
case models.LeadHoldSourceCC:
// The reason is the lead they are copied on; no end is expected.
if r := strings.TrimSpace(hold.Reason); r != "" {
return "Copied on the emails to " + r + ", so none of their own are sent"
}
return "Copied on another lead's emails, so none of their own are sent"
}
if r := strings.TrimSpace(hold.Reason); r != "" {
what += ": " + r
+6
View File
@@ -462,6 +462,12 @@ var Tables = []Table{
Name: "campaign_segments", Group: models.OrgDataGroupCampaigns,
Scope: `campaign_id IN ` + orgCampaigns,
},
{
// Both contacts are in the contacts group, which campaigns require.
Name: "campaign_lead_cc", Group: models.OrgDataGroupCampaigns,
Scope: `campaign_id IN ` + orgCampaigns,
Note: "Must travel with the leads, or a copied contact held on their own lead is released into a second sequence.",
},
{
Name: "campaign_lead_removals", Group: models.OrgDataGroupCampaigns,
Scope: `campaign_id IN ` + orgCampaigns,
+1
View File
@@ -563,5 +563,6 @@ func MailErrorToSendError(err *errx.MailError) *models.EmailSendError {
UserTitle: userInfo.Title,
UserMessage: userInfo.Message,
ActionRequired: userInfo.ActionRequired,
Recipient: err.Recipient,
}
}
+3 -1
View File
@@ -475,7 +475,9 @@ func (c *Client) sendRaw(ctx context.Context, from string, to []string, data []b
if !permanentReply(err) {
return errx.ErrMailServerUnreachableAt("rcpt to", err)
}
return errx.ErrMailRecipientRejected(err.Error())
refused := errx.ErrMailRecipientRejected(err.Error())
refused.Recipient = r
return refused
}
}
w, err := client.Data()
@@ -412,4 +412,8 @@ func TestRecipientRejectionCarriesTheServersReason(t *testing.T) {
if !strings.Contains(err.Message, "no such user here") {
t.Errorf("message = %q, want the server's own reason in it", err.Message)
}
// Named apart from the message, so a refused copy is not read as the lead.
if err.Recipient != "to@example.test" {
t.Errorf("recipient = %q, want the refused address", err.Recipient)
}
}
+4
View File
@@ -159,6 +159,10 @@ const (
// tick retries it; this bounds that loop for a mailbox that can never send.
CampaignSendMaxAttempts = 5
// CampaignLeadMaxCC caps the contacts copied on one lead's emails. Every
// copy is one more recipient who did not ask for the email.
CampaignLeadMaxCC = 2
// CampaignNotDueGraceSeconds is how far in the future a step's hard
// constraints (wait_after, start date, sending window, mailbox min-gap)
// may sit while a firing task still sends it. Beyond this the scheduler
+2
View File
@@ -109,6 +109,8 @@ type MailError struct {
ResolvedAt *time.Time `json:"resolved_at"`
Message string `json:"message"`
// Recipient is the one address a per-recipient refusal was about.
Recipient string `json:"recipient,omitempty"`
// RetryAfter is provider guidance for transient throttles. It stays local
// to the worker; persisted error records should not depend on a stale delay.
@@ -0,0 +1,16 @@
DROP TRIGGER IF EXISTS campaign_lead_enrol_cc_hold ON campaign_leads;
DROP FUNCTION IF EXISTS campaign_lead_enrol_cc_hold();
DROP TRIGGER IF EXISTS campaign_lead_cc_hold ON campaign_lead_cc;
DROP FUNCTION IF EXISTS campaign_lead_cc_hold();
DROP TABLE IF EXISTS campaign_lead_cc;
-- The source is gone, so its holds go with it rather than failing the check.
UPDATE campaign_leads
SET paused_at = NULL, paused_until = NULL, pause_reason = NULL, pause_source = NULL
WHERE pause_source = 'cc';
ALTER TABLE public.campaign_leads DROP CONSTRAINT IF EXISTS campaign_leads_pause_source_check;
ALTER TABLE public.campaign_leads
ADD CONSTRAINT campaign_leads_pause_source_check
CHECK (pause_source IS NULL OR pause_source IN ('manual', 'out_of_office', 'inbox_tagging')) NOT VALID;
@@ -0,0 +1,93 @@
-- Extra recipients copied on every email one campaign sends one lead, so two
-- people at the same company can be reached in one thread instead of two
-- parallel sequences (issue #731). The copies are contacts, not free text, so
-- suppression, bounces and verification apply to them exactly as to a lead.
CREATE TABLE campaign_lead_cc (
campaign_id uuid NOT NULL,
contact_id uuid NOT NULL,
cc_contact_id uuid NOT NULL REFERENCES contacts (id) ON DELETE CASCADE,
position smallint NOT NULL DEFAULT 0,
-- A bounce attributed to this copy on this lead's thread. It is dropped
-- from later emails whatever the workspace's auto-suppress setting says.
bounced_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (campaign_id, contact_id, cc_contact_id),
FOREIGN KEY (campaign_id, contact_id) REFERENCES campaign_leads (campaign_id, contact_id) ON DELETE CASCADE,
CONSTRAINT campaign_lead_cc_not_self CHECK (cc_contact_id <> contact_id)
);
-- Serves the contact FK's cascade and "is this contact copied on a lead here".
CREATE INDEX idx_campaign_lead_cc_cc ON campaign_lead_cc (cc_contact_id, campaign_id);
-- A contact copied on another lead's thread is reached there, so their own
-- lead in the same campaign is held with source 'cc' instead of starting a
-- second sequence.
ALTER TABLE public.campaign_leads DROP CONSTRAINT IF EXISTS campaign_leads_pause_source_check;
-- NOT VALID: 000231 validates it in its own transaction.
ALTER TABLE public.campaign_leads
ADD CONSTRAINT campaign_leads_pause_source_check
CHECK (pause_source IS NULL OR pause_source IN ('manual', 'out_of_office', 'inbox_tagging', 'cc')) NOT VALID;
-- Triggers rather than callers, so every path that enrols a lead (segments,
-- imports, contact edits, org transfer) holds a copied contact the same way.
CREATE FUNCTION campaign_lead_cc_hold() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
-- A member's own live pause outranks this; anything else is replaced.
UPDATE campaign_leads cl
SET paused_at = CASE
WHEN cl.paused_at IS NOT NULL AND (cl.paused_until IS NULL OR cl.paused_until > NOW())
THEN cl.paused_at ELSE NOW() END,
paused_until = NULL,
pause_reason = (SELECT c.email FROM contacts c WHERE c.id = NEW.contact_id),
pause_source = 'cc'
WHERE cl.campaign_id = NEW.campaign_id
AND cl.contact_id = NEW.cc_contact_id
AND NOT (cl.pause_source = 'manual' AND cl.paused_at IS NOT NULL
AND (cl.paused_until IS NULL OR cl.paused_until > NOW()));
RETURN NEW;
END IF;
-- Released once no lead in the campaign copies them any more.
UPDATE campaign_leads cl
SET paused_at = NULL, paused_until = NULL, pause_reason = NULL, pause_source = NULL
WHERE cl.campaign_id = OLD.campaign_id
AND cl.contact_id = OLD.cc_contact_id
AND cl.pause_source = 'cc'
AND NOT EXISTS (
SELECT 1 FROM campaign_lead_cc x
WHERE x.campaign_id = OLD.campaign_id AND x.cc_contact_id = OLD.cc_contact_id
);
RETURN OLD;
END;
$$;
CREATE TRIGGER campaign_lead_cc_hold
AFTER INSERT OR DELETE ON campaign_lead_cc
FOR EACH ROW EXECUTE FUNCTION campaign_lead_cc_hold();
-- The other order: a contact already copied on a lead is enrolled later.
CREATE FUNCTION campaign_lead_enrol_cc_hold() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
lead_email text;
BEGIN
SELECT c.email INTO lead_email
FROM campaign_lead_cc x
JOIN contacts c ON c.id = x.contact_id
WHERE x.campaign_id = NEW.campaign_id AND x.cc_contact_id = NEW.contact_id
ORDER BY x.created_at
LIMIT 1;
IF FOUND AND NEW.paused_at IS NULL THEN
NEW.paused_at := NOW();
NEW.paused_until := NULL;
NEW.pause_reason := lead_email;
NEW.pause_source := 'cc';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER campaign_lead_enrol_cc_hold
BEFORE INSERT ON campaign_leads
FOR EACH ROW EXECUTE FUNCTION campaign_lead_enrol_cc_hold();
@@ -0,0 +1,3 @@
-- A validated constraint has no unvalidated form to return to; 000230's down
-- migration replaces it.
SELECT 1;
@@ -0,0 +1,3 @@
-- Validates the pause_source CHECK 000230 re-added NOT VALID. A VALIDATE only
-- takes a SHARE UPDATE EXCLUSIVE lock, so writes continue while it scans.
ALTER TABLE public.campaign_leads VALIDATE CONSTRAINT campaign_leads_pause_source_check;
+1 -1
View File
@@ -53,7 +53,7 @@ const (
AuditEntityCampaign AuditEntityType = "campaign"
// AuditEntityCampaignLead is ONE contact inside ONE campaign: the entity id
// is the contact and metadata carries the campaign. Written when a member
// pauses or resumes that lead's flow.
// pauses or resumes that lead's flow, or changes who it copies.
AuditEntityCampaignLead AuditEntityType = "campaign_lead"
AuditEntityContact AuditEntityType = "contact"
AuditEntityEmailAccount AuditEntityType = "email_account"
+60
View File
@@ -0,0 +1,60 @@
package models
import (
"time"
"github.com/google/uuid"
)
// CampaignLeadCC is a contact copied on every email one campaign sends one
// lead, so several people at one company share a single thread (issue #731).
type CampaignLeadCC struct {
ContactID uuid.UUID `json:"contact_id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Company string `json:"company,omitempty"`
// Status says whether the next email copies them: one of the
// LeadCCStatus constants. Anything but "active" is left off.
Status string `json:"status"`
BouncedAt *time.Time `json:"bounced_at,omitempty"`
}
// Copied reports whether the next email to the lead carries this address.
func (c CampaignLeadCC) Copied() bool { return c.Status == LeadCCStatusActive }
// Why a copy is or is not on the next email, in the order they are decided.
const (
LeadCCStatusActive = "active"
// LeadCCStatusUnsubscribed is an opted-out or suppressed address.
LeadCCStatusUnsubscribed = "unsubscribed"
// LeadCCStatusBounced is an address that bounced on this thread or on any
// campaign email of its own.
LeadCCStatusBounced = "bounced"
// LeadCCStatusUndeliverable is an address verification refused, under the
// same rule the campaign applies to its leads.
LeadCCStatusUndeliverable = "undeliverable"
)
// LeadHoldSourceCC holds a contact's own lead while they are copied on another
// lead's thread in the same campaign, so they never get two sequences. Written
// only by the campaign_lead_cc triggers (migration 000230).
const LeadHoldSourceCC = "cc"
// SetCampaignLeadCC replaces the contacts copied on one lead. An empty list
// removes them all.
type SetCampaignLeadCC struct {
ContactIDs []string `json:"contact_ids"`
}
// CampaignLeadCCSuggestion is a contact who looks like a colleague of the
// lead, offered first when picking who to copy. Reason is "company" when the
// company names match and "domain" when only the email domain does.
type CampaignLeadCCSuggestion struct {
ContactID uuid.UUID `json:"contact_id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Company string `json:"company,omitempty"`
Reason string `json:"reason"`
}
+2
View File
@@ -111,6 +111,8 @@ type ContactCampaignProgress struct {
// Hold is the per-lead pause, set only while it is live. Present on any
// status: a held lead that has also replied still reads "replied".
Hold *LeadHold `json:"hold,omitempty"`
// CC is the contacts copied on every email to this lead in this campaign.
CC []CampaignLeadCC `json:"cc,omitempty"`
}
// LeadHold is one contact's flow parked inside one campaign. Source is
@@ -37,6 +37,9 @@ type ContactCampaignState struct {
// hand. The drawer renders it with "resume now" and "stop" next to it.
Hold *LeadHold `json:"hold,omitempty"`
// CC is the contacts copied on every email to this lead in this campaign.
CC []CampaignLeadCC `json:"cc"`
// Next is nil once the flow has ended for the contact; EndedReason says why.
Next *ContactNextAction `json:"next,omitempty"`
EndedReason string `json:"ended_reason,omitempty"`
+3
View File
@@ -111,6 +111,9 @@ type EmailSendError struct {
UserTitle string `json:"user_title,omitempty" avro:"user_title"`
UserMessage string `json:"user_message,omitempty" avro:"user_message"`
ActionRequired string `json:"action_required,omitempty" avro:"action_required"`
// Recipient is the address a refusal named, when the server refused one
// recipient rather than the message.
Recipient string `json:"recipient,omitempty" avro:"recipient"`
}
// SendEmailResult is the result from worker after sending email
@@ -0,0 +1,264 @@
package repository
import (
"context"
"errors"
"strings"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
// Contacts copied on one lead's emails (issue #731). The hold that keeps a
// copied contact from getting a second sequence lives in triggers, so it is
// asserted against the real routing query.
//
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/<db>?sslmode=disable \
// go test ./internal/repository/ -run LiveLeadCC -v
func routedIDs(pairs []ContactSequencePair) map[uuid.UUID]bool {
out := map[uuid.UUID]bool{}
for _, p := range pairs {
out[p.ContactID] = true
}
return out
}
func TestLiveLeadCCHoldsTheCopiedLeadUntilReleased(t *testing.T) {
_, pool := liveContactDB(t)
f := newRoutedPairsFixture(t, pool, 3)
repo := NewCampaignProgressRepository(pool)
ctx := context.Background()
a, b, c := f.leads[0], f.leads[1], f.leads[2]
if err := repo.SetLeadCC(ctx, f.org, f.campaign, a, []uuid.UUID{b}); err != nil {
t.Fatalf("SetLeadCC: %v", err)
}
pairs, _, _ := f.find(t, nil, 25)
got := routedIDs(pairs)
if !got[a] || got[b] || !got[c] {
t.Fatalf("routed %v; want the lead and the uncopied lead, not the copied one", got)
}
hold, err := repo.GetLeadHold(ctx, f.campaign, b)
if err != nil || hold == nil || hold.Source != models.LeadHoldSourceCC {
t.Fatalf("copied lead's hold = %+v, %v; want a cc hold", hold, err)
}
// Nothing is left to wait for, so a campaign of only copied leads can finish.
if n, err := repo.CountHeldLeads(ctx, f.campaign); err != nil || n != 0 {
t.Fatalf("CountHeldLeads = %d, %v; want 0 for a cc hold", n, err)
}
cc, err := repo.ListLeadCC(ctx, f.campaign, a)
if err != nil || len(cc) != 1 || cc[0].ContactID != b || !cc[0].Copied() {
t.Fatalf("ListLeadCC = %+v, %v; want the copy, active", cc, err)
}
if err := repo.SetLeadCC(ctx, f.org, f.campaign, a, nil); err != nil {
t.Fatalf("SetLeadCC to nobody: %v", err)
}
if hold, err := repo.GetLeadHold(ctx, f.campaign, b); err != nil || hold != nil {
t.Fatalf("hold after release = %+v, %v; want none", hold, err)
}
pairs, _, _ = f.find(t, nil, 25)
if !routedIDs(pairs)[b] {
t.Fatal("a released copy was not routed again")
}
}
// A contact copied first and enrolled afterwards is held on arrival, whichever
// path enrols them.
func TestLiveLeadCCHoldsAContactEnrolledAfterBeingCopied(t *testing.T) {
_, pool := liveContactDB(t)
f := newRoutedPairsFixture(t, pool, 2)
repo := NewCampaignProgressRepository(pool)
ctx := context.Background()
a, b := f.leads[0], f.leads[1]
if _, err := pool.Exec(ctx, `DELETE FROM campaign_leads WHERE campaign_id = $1 AND contact_id = $2`, f.campaign, b); err != nil {
t.Fatalf("drop lead: %v", err)
}
if err := repo.SetLeadCC(ctx, f.org, f.campaign, a, []uuid.UUID{b}); err != nil {
t.Fatalf("SetLeadCC: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO campaign_leads (campaign_id, contact_id) VALUES ($1, $2)`, f.campaign, b); err != nil {
t.Fatalf("enrol: %v", err)
}
if hold, err := repo.GetLeadHold(ctx, f.campaign, b); err != nil || hold == nil || hold.Source != models.LeadHoldSourceCC {
t.Fatalf("hold on enrol = %+v, %v; want a cc hold", hold, err)
}
// Removing the lead that copies them releases them too.
if _, err := pool.Exec(ctx, `DELETE FROM campaign_leads WHERE campaign_id = $1 AND contact_id = $2`, f.campaign, a); err != nil {
t.Fatalf("drop copying lead: %v", err)
}
if hold, err := repo.GetLeadHold(ctx, f.campaign, b); err != nil || hold != nil {
t.Fatalf("hold after the copying lead left = %+v, %v; want none", hold, err)
}
}
func TestLiveLeadCCRefusals(t *testing.T) {
_, pool := liveContactDB(t)
f := newRoutedPairsFixture(t, pool, 3)
other := newRoutedPairsFixture(t, pool, 1)
repo := NewCampaignProgressRepository(pool)
ctx := context.Background()
a, b, c := f.leads[0], f.leads[1], f.leads[2]
cases := []struct {
name string
lead uuid.UUID
cc []uuid.UUID
want error
}{
{"self", a, []uuid.UUID{a}, ErrLeadCCSelf},
{"another workspace's contact", a, []uuid.UUID{other.leads[0]}, ErrLeadCCContactNotFound},
{"not a lead", other.leads[0], nil, ErrLeadNotInCampaign},
}
for _, tc := range cases {
if err := repo.SetLeadCC(ctx, f.org, f.campaign, tc.lead, tc.cc); !errors.Is(err, tc.want) {
t.Fatalf("%s: err = %v, want %v", tc.name, err, tc.want)
}
}
if err := repo.SetLeadCC(ctx, f.org, f.campaign, a, []uuid.UUID{b}); err != nil {
t.Fatalf("SetLeadCC: %v", err)
}
if err := repo.SetLeadCC(ctx, f.org, f.campaign, b, []uuid.UUID{c}); !errors.Is(err, ErrLeadCCLeadIsCopied) {
t.Fatalf("copies on a copied lead: err = %v, want ErrLeadCCLeadIsCopied", err)
}
if err := repo.SetLeadCC(ctx, f.org, f.campaign, c, []uuid.UUID{a}); !errors.Is(err, ErrLeadCCHasCopies) {
t.Fatalf("copying a lead with copies: err = %v, want ErrLeadCCHasCopies", err)
}
// A refused write leaves the list as it was.
if cc, _ := repo.ListLeadCC(ctx, f.campaign, a); len(cc) != 1 || cc[0].ContactID != b {
t.Fatalf("list after refusals = %+v; want it unchanged", cc)
}
}
func TestLiveLeadCCStatusAndBounces(t *testing.T) {
_, pool := liveContactDB(t)
f := newRoutedPairsFixture(t, pool, 3)
repo := NewCampaignProgressRepository(pool)
ctx := context.Background()
a, b, c := f.leads[0], f.leads[1], f.leads[2]
if err := repo.SetLeadCC(ctx, f.org, f.campaign, a, []uuid.UUID{b, c}); err != nil {
t.Fatalf("SetLeadCC: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE contacts SET subscribed = false WHERE id = $1`, b); err != nil {
t.Fatalf("unsubscribe: %v", err)
}
var cEmail string
if err := pool.QueryRow(ctx, `SELECT email FROM contacts WHERE id = $1`, c).Scan(&cEmail); err != nil {
t.Fatalf("read email: %v", err)
}
id, err := repo.MarkLeadCCBounced(ctx, f.campaign, a, strings.ToUpper(cEmail))
if err != nil || id == nil || *id != c {
t.Fatalf("MarkLeadCCBounced = %v, %v; want the copy", id, err)
}
if id, err := repo.MarkLeadCCBounced(ctx, f.campaign, a, "nobody@test.local"); err != nil || id != nil {
t.Fatalf("MarkLeadCCBounced on a stranger = %v, %v; want nil", id, err)
}
cc, err := repo.ListLeadCC(ctx, f.campaign, a)
if err != nil || len(cc) != 2 {
t.Fatalf("ListLeadCC = %+v, %v", cc, err)
}
want := map[uuid.UUID]string{b: models.LeadCCStatusUnsubscribed, c: models.LeadCCStatusBounced}
for _, x := range cc {
if x.Status != want[x.ContactID] || x.Copied() {
t.Fatalf("copy %s status %q; want %q and not copied", x.ContactID, x.Status, want[x.ContactID])
}
}
}
func TestLiveLeadForCopiedReplyFindsTheLeadsThread(t *testing.T) {
_, pool := liveContactDB(t)
f := newRoutedPairsFixture(t, pool, 2)
repo := NewCampaignProgressRepository(pool)
ctx := context.Background()
a, b := f.leads[0], f.leads[1]
if err := repo.SetLeadCC(ctx, f.org, f.campaign, a, []uuid.UUID{b}); err != nil {
t.Fatalf("SetLeadCC: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE campaign_leads SET email_account_id = $3 WHERE campaign_id = $1 AND contact_id = $2`,
f.campaign, a, f.mailbox); err != nil {
t.Fatalf("bind sender: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at)
VALUES ($1, $2, $3, NOW())`, f.campaign, a, f.step); err != nil {
t.Fatalf("progress: %v", err)
}
ref, err := repo.LeadForCopiedReply(ctx, b, f.mailbox)
if err != nil || ref == nil || ref.CampaignID != f.campaign || ref.ContactID != a || ref.SequenceID != f.step {
t.Fatalf("LeadForCopiedReply = %+v, %v; want the lead's step", ref, err)
}
// Another mailbox never wrote to the lead, so it is no evidence.
if ref, err := repo.LeadForCopiedReply(ctx, b, uuid.New()); err != nil || ref != nil {
t.Fatalf("LeadForCopiedReply from another mailbox = %+v, %v; want nil", ref, err)
}
}
// The Leads list carries each lead's copies, and a copied lead reads paused.
func TestLiveLeadCCShowsInTheLeadsList(t *testing.T) {
handle, pool := liveContactDB(t)
f := newRoutedPairsFixture(t, pool, 2)
repo := NewCampaignProgressRepository(pool)
contacts := NewContactRepostory(handle)
ctx := context.Background()
a, b := f.leads[0], f.leads[1]
if err := repo.SetLeadCC(ctx, f.org, f.campaign, a, []uuid.UUID{b}); err != nil {
t.Fatalf("SetLeadCC: %v", err)
}
res, xerr := contacts.Search(ctx, f.org.String(), nil, nil, models.SearchContacts{
CampaignIDs: []string{f.campaign.String()},
}, 25)
if xerr != nil {
t.Fatalf("search: %v", xerr)
}
byID := map[uuid.UUID]*models.ContactCampaignProgress{}
for i := range res.Data {
byID[res.Data[i].ID] = res.Data[i].CampaignLead
}
if lead := byID[a]; lead == nil || len(lead.CC) != 1 || lead.CC[0].ContactID != b || lead.CC[0].Status != models.LeadCCStatusActive {
t.Fatalf("the copying lead reads %+v; want its one active copy", lead)
}
if lead := byID[b]; lead == nil || lead.Status != models.LeadStatusPaused || lead.Hold == nil || lead.Hold.Source != models.LeadHoldSourceCC {
t.Fatalf("the copied lead reads %+v; want paused with a cc hold", lead)
}
}
// A refused copy walks the step back without spending the lead's attempt, and
// a campaign-wide copy that bounced here is reported so the send leaves it off.
func TestLiveLeadCCRefusedCopyCostsNoAttempt(t *testing.T) {
_, pool := liveContactDB(t)
f := newRoutedPairsFixture(t, pool, 1)
repo := NewCampaignProgressRepository(pool)
ctx := context.Background()
lead := f.leads[0]
if _, err := pool.Exec(ctx, `INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at, dispatched_at)
VALUES ($1, $2, $3, NOW(), NOW())`, f.campaign, lead, f.step); err != nil {
t.Fatalf("progress: %v", err)
}
attempts, _, rolled, err := repo.WalkBackSend(ctx, f.campaign, lead, f.step, "copy refused", false)
if err != nil || !rolled || attempts != 0 {
t.Fatalf("WalkBackSend = %d, %v, %v; want rolled back with no attempt", attempts, rolled, err)
}
if _, err := pool.Exec(ctx, `INSERT INTO deliverability_events (organization_id, campaign_id, event_type, recipient_email, idempotency_key)
VALUES ($1, $2, 'bounce', 'Crm@Acme.test', $3)`, f.org, f.campaign, "test:"+uuid.NewString()); err != nil {
t.Fatalf("event: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM deliverability_events WHERE organization_id = $1`, f.org)
})
got, err := repo.BouncedCopyAddresses(ctx, f.campaign, []string{"crm@acme.test", "boss@acme.test"})
if err != nil || !got["crm@acme.test"] || got["boss@acme.test"] {
t.Fatalf("BouncedCopyAddresses = %v, %v; want only the bounced address", got, err)
}
}
+285
View File
@@ -0,0 +1,285 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/models"
)
// What SetLeadCC refuses. The service turns each into its own error code.
var (
// ErrLeadCCContactNotFound is a copy that is not a contact of the workspace.
ErrLeadCCContactNotFound = errors.New("a contact to copy was not found")
// ErrLeadCCSelf is the lead copied on their own emails.
ErrLeadCCSelf = errors.New("a lead cannot be copied on their own emails")
// ErrLeadCCLeadIsCopied is a lead already copied on another lead's thread
// in the campaign; their own emails are held, so copies would reach nobody.
ErrLeadCCLeadIsCopied = errors.New("the lead is copied on another lead in this campaign")
// ErrLeadCCHasCopies is a contact whose own lead copies others: holding it
// would silently strand the people it copies.
ErrLeadCCHasCopies = errors.New("a contact to copy has copies of their own in this campaign")
)
// CopiedLeadRef names the lead, and the step, a copied contact's reply answers.
type CopiedLeadRef struct {
CampaignID uuid.UUID
ContactID uuid.UUID
SequenceID uuid.UUID
}
// personalMailDomainsSQL never identify a company, so a shared one is not a
// reason to suggest two contacts are colleagues.
const personalMailDomainsSQL = `'gmail.com','googlemail.com','yahoo.com','yahoo.de','hotmail.com','hotmail.de','outlook.com','outlook.de','live.com','live.de','msn.com','aol.com','icloud.com','me.com','gmx.com','gmx.de','gmx.net','web.de','t-online.de','freenet.de','posteo.de','mailbox.org','proton.me','protonmail.com','mail.com','yandex.com'`
// leadCCStatusSQL derives models.LeadCCStatus* for a copied contact aliased c
// on the campaign_lead_cc row aliased x. cp is the bound campaign id.
func leadCCStatusSQL(cp string) string {
return `CASE
WHEN c.subscribed IS FALSE
OR recipient_suppressed((SELECT organization_id FROM campaigns WHERE id = ` + cp + `), c.email)
THEN '` + models.LeadCCStatusUnsubscribed + `'
WHEN x.bounced_at IS NOT NULL
OR EXISTS (SELECT 1 FROM campaign_contact_progress b WHERE b.contact_id = c.id AND b.bounced_at IS NOT NULL)
THEN '` + models.LeadCCStatusBounced + `'
WHEN ` + undeliverableClause(cp) + ` THEN '` + models.LeadCCStatusUndeliverable + `'
ELSE '` + models.LeadCCStatusActive + `'
END`
}
// leadCCSelectSQL lists one lead's copies in the order they were chosen. $1 is
// the campaign, $2 the lead.
func leadCCSelectSQL() string {
return `
SELECT c.id, c.email, c.first_name, c.last_name, c.company, ` + leadCCStatusSQL("$1") + `, x.bounced_at
FROM campaign_lead_cc x
JOIN contacts c ON c.id = x.cc_contact_id
WHERE x.campaign_id = $1 AND x.contact_id = $2
ORDER BY x.position, x.created_at`
}
// listLeadCC is shared by the campaign and contact repositories, so the send
// path and the drawer read one definition.
func listLeadCC(ctx context.Context, pool *pgxpool.Pool, campaignID, contactID uuid.UUID) ([]models.CampaignLeadCC, error) {
rows, err := pool.Query(ctx, leadCCSelectSQL(), campaignID, contactID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.CampaignLeadCC{}
for rows.Next() {
var cc models.CampaignLeadCC
if err := rows.Scan(&cc.ContactID, &cc.Email, &cc.FirstName, &cc.LastName, &cc.Company, &cc.Status, &cc.BouncedAt); err != nil {
return nil, err
}
out = append(out, cc)
}
return out, rows.Err()
}
func (r *campaignProgressRepository) ListLeadCC(ctx context.Context, campaignID, contactID uuid.UUID) ([]models.CampaignLeadCC, error) {
return listLeadCC(ctx, r.db, campaignID, contactID)
}
func (r *campaignProgressRepository) SetLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID, ccIDs []uuid.UUID) error {
tx, err := r.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// One writer per campaign, so two edits cannot build a chain of copies
// that each checked against the other's snapshot.
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('campaign_lead_cc:' || $1::text, 0))`, campaignID); err != nil {
return err
}
var isLead bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM campaign_leads cl
JOIN campaigns cam ON cam.id = cl.campaign_id AND cam.organization_id = $1
WHERE cl.campaign_id = $2 AND cl.contact_id = $3
)`, orgID, campaignID, contactID).Scan(&isLead); err != nil {
return err
}
if !isLead {
return ErrLeadNotInCampaign
}
if len(ccIDs) > 0 {
for _, id := range ccIDs {
if id == contactID {
return ErrLeadCCSelf
}
}
var found int
if err := tx.QueryRow(ctx,
`SELECT COUNT(*) FROM contacts WHERE organization_id = $1 AND id = ANY($2::uuid[])`,
orgID, ccIDs).Scan(&found); err != nil {
return err
}
if found != len(ccIDs) {
return ErrLeadCCContactNotFound
}
var leadIsCopied, ccHasCopies bool
if err := tx.QueryRow(ctx, `
SELECT
EXISTS (SELECT 1 FROM campaign_lead_cc WHERE campaign_id = $1 AND cc_contact_id = $2),
EXISTS (SELECT 1 FROM campaign_lead_cc WHERE campaign_id = $1 AND contact_id = ANY($3::uuid[]))`,
campaignID, contactID, ccIDs).Scan(&leadIsCopied, &ccHasCopies); err != nil {
return err
}
if leadIsCopied {
return ErrLeadCCLeadIsCopied
}
if ccHasCopies {
return ErrLeadCCHasCopies
}
}
if _, err := tx.Exec(ctx, `
DELETE FROM campaign_lead_cc
WHERE campaign_id = $1 AND contact_id = $2
AND NOT (cc_contact_id = ANY(COALESCE($3::uuid[], '{}')))`,
campaignID, contactID, ccIDs); err != nil {
return err
}
if len(ccIDs) > 0 {
if _, err := tx.Exec(ctx, `
INSERT INTO campaign_lead_cc (campaign_id, contact_id, cc_contact_id, position)
SELECT $1, $2, u.id, u.ord - 1
FROM unnest($3::uuid[]) WITH ORDINALITY AS u(id, ord)
ON CONFLICT (campaign_id, contact_id, cc_contact_id) DO UPDATE SET position = EXCLUDED.position`,
campaignID, contactID, ccIDs); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (r *campaignProgressRepository) MarkLeadCCBounced(ctx context.Context, campaignID, contactID uuid.UUID, address string) (*uuid.UUID, error) {
address = strings.TrimSpace(address)
if address == "" {
return nil, nil
}
var id uuid.UUID
err := r.db.QueryRow(ctx, `
UPDATE campaign_lead_cc x
SET bounced_at = COALESCE(x.bounced_at, NOW())
FROM contacts c
WHERE c.id = x.cc_contact_id
AND x.campaign_id = $1 AND x.contact_id = $2
AND lower(c.email) = lower($3)
RETURNING x.cc_contact_id`, campaignID, contactID, address).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &id, nil
}
func (r *campaignProgressRepository) LeadForCopiedReply(ctx context.Context, ccContactID, emailAccountID uuid.UUID) (*CopiedLeadRef, error) {
var ref CopiedLeadRef
err := r.db.QueryRow(ctx, `
SELECT p.campaign_id, p.contact_id, p.sequence_id
FROM campaign_lead_cc x
JOIN campaign_leads cl ON cl.campaign_id = x.campaign_id AND cl.contact_id = x.contact_id
JOIN campaign_contact_progress p ON p.campaign_id = x.campaign_id AND p.contact_id = x.contact_id
WHERE x.cc_contact_id = $1
AND cl.email_account_id = $2
AND p.sent_at IS NOT NULL
AND `+progressIsEmailStep("p")+`
ORDER BY p.sent_at DESC
LIMIT 1`, ccContactID, emailAccountID).Scan(&ref.CampaignID, &ref.ContactID, &ref.SequenceID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &ref, nil
}
func (r *campaignProgressRepository) SuggestLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID, limit int) ([]models.CampaignLeadCCSuggestion, error) {
rows, err := r.db.Query(ctx, fmt.Sprintf(`
WITH lead AS (
SELECT lower(btrim(company)) AS co, lower(split_part(email, '@', 2)) AS dom
FROM contacts WHERE id = $2 AND organization_id = $1
)
SELECT c.id, c.email, c.first_name, c.last_name, c.company,
CASE WHEN lead.co <> '' AND lower(btrim(c.company)) = lead.co THEN 'company' ELSE 'domain' END AS reason
FROM contacts c, lead
WHERE c.organization_id = $1
AND c.id <> $2
AND c.subscribed IS NOT FALSE
AND (
(lead.co <> '' AND lower(btrim(c.company)) = lead.co)
OR (lead.dom <> '' AND lead.dom NOT IN (%s) AND lower(split_part(c.email, '@', 2)) = lead.dom)
)
AND NOT EXISTS (
SELECT 1 FROM campaign_lead_cc x
WHERE x.campaign_id = $3 AND x.contact_id = $2 AND x.cc_contact_id = c.id
)
ORDER BY (lead.co <> '' AND lower(btrim(c.company)) = lead.co) DESC, c.first_name, c.last_name, c.email
LIMIT $4`, personalMailDomainsSQL), orgID, contactID, campaignID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.CampaignLeadCCSuggestion{}
for rows.Next() {
var s models.CampaignLeadCCSuggestion
if err := rows.Scan(&s.ContactID, &s.Email, &s.FirstName, &s.LastName, &s.Company, &s.Reason); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (r *campaignProgressRepository) BouncedCopyAddresses(ctx context.Context, campaignID uuid.UUID, addresses []string) (map[string]bool, error) {
out := map[string]bool{}
if len(addresses) == 0 {
return out, nil
}
rows, err := r.db.Query(ctx, `
SELECT DISTINCT lower(recipient_email)
FROM deliverability_events
WHERE campaign_id = $1 AND event_type = 'bounce'
AND lower(recipient_email) = ANY($2::text[])`, campaignID, addresses)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
return nil, err
}
out[a] = true
}
return out, rows.Err()
}
// leadCCJSONSQL is one lead's copies as a JSON array for the Leads list, with
// the lead row aliased hl and the campaign bound at cp.
func leadCCJSONSQL(cp string) string {
return `(
SELECT COALESCE(json_agg(json_build_object(
'contact_id', c.id, 'email', c.email, 'first_name', c.first_name,
'last_name', c.last_name, 'company', c.company,
'status', ` + leadCCStatusSQL(cp) + `, 'bounced_at', x.bounced_at
) ORDER BY x.position, x.created_at), '[]'::json)
FROM campaign_lead_cc x
JOIN contacts c ON c.id = x.cc_contact_id
WHERE x.campaign_id = hl.campaign_id AND x.contact_id = hl.contact_id
)`
}
+37 -3
View File
@@ -165,6 +165,12 @@ type CampaignProgressRepository interface {
// A lead with nothing else delivered is unbound from the mailbox that
// failed, so rotation can offer it a working one.
RecordSendFailure(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, reason string) (attempts int, exhausted bool, rolledBack bool, err error)
// WalkBackSend is RecordSendFailure with the attempt optionally left
// uncounted, for a failure the retry is known not to repeat.
WalkBackSend(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, reason string, countAttempt bool) (attempts int, exhausted bool, rolledBack bool, err error)
// BouncedCopyAddresses reports which of the campaign's own CC/BCC
// addresses have bounced on a send of this campaign.
BouncedCopyAddresses(ctx context.Context, campaignID uuid.UUID, addresses []string) (map[string]bool, error)
// LastSenderForLead is the mailbox a lead was LAST actually sent from,
// read from the campaign tasks that dispatched its steps. It answers the
// case campaign_leads.email_account_id cannot: a lead removed from the
@@ -328,6 +334,24 @@ type CampaignProgressRepository interface {
// including a dated hold that has since expired). Returns
// ErrLeadNotInCampaign when the contact is not a lead of the campaign.
GetLeadHold(ctx context.Context, campaignID, contactID uuid.UUID) (*models.LeadHold, error)
// ListLeadCC reads the contacts copied on one lead, each with whether the
// next email carries them.
ListLeadCC(ctx context.Context, campaignID, contactID uuid.UUID) ([]models.CampaignLeadCC, error)
// SetLeadCC replaces the contacts copied on one lead. See the ErrLeadCC
// errors for what it refuses.
SetLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID, ccIDs []uuid.UUID) error
// MarkLeadCCBounced records a bounce on the copy of one lead's thread sent
// to address, and returns that copy's contact, or nil when address is not
// one of the lead's copies.
MarkLeadCCBounced(ctx context.Context, campaignID, contactID uuid.UUID, address string) (*uuid.UUID, error)
// LeadForCopiedReply finds the lead whose thread a copied contact is
// answering in: the latest email step sent from emailAccountID to a lead
// that copies them. Nil when there is none.
LeadForCopiedReply(ctx context.Context, ccContactID, emailAccountID uuid.UUID) (*CopiedLeadRef, error)
// SuggestLeadCC offers the lead's likely colleagues: same company name, or
// the same email domain when that domain is not a personal mail service.
SuggestLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID, limit int) ([]models.CampaignLeadCCSuggestion, error)
}
// ErrLeadNotInCampaign is returned when a hold is asked for on a contact that
@@ -564,6 +588,14 @@ func (r *campaignProgressRepository) ListStuckDispatches(ctx context.Context, ol
// so a duplicate worker result after the step was already walked back (or
// re-sent) is a no-op.
func (r *campaignProgressRepository) RecordSendFailure(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, reason string) (int, bool, bool, error) {
return r.WalkBackSend(ctx, campaignID, contactID, sequenceID, reason, true)
}
func (r *campaignProgressRepository) WalkBackSend(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, reason string, countAttempt bool) (int, bool, bool, error) {
inc := 0
if countAttempt {
inc = 1
}
if len(reason) > 500 {
reason = reason[:500]
}
@@ -578,7 +610,7 @@ func (r *campaignProgressRepository) RecordSendFailure(ctx context.Context, camp
SET sent_at = NULL,
dispatched_at = NULL,
dispatch_task_id = NULL,
send_attempts = send_attempts + 1,
send_attempts = send_attempts + $5,
failed_at = NOW(),
failure_reason = $4
WHERE campaign_id = $1 AND contact_id = $2 AND sequence_id = $3
@@ -599,7 +631,7 @@ func (r *campaignProgressRepository) RecordSendFailure(ctx context.Context, camp
SELECT send_attempts FROM walked
`
var attempts int
err := r.db.QueryRow(ctx, query, campaignID, contactID, sequenceID, reason).Scan(&attempts)
err := r.db.QueryRow(ctx, query, campaignID, contactID, sequenceID, reason, inc).Scan(&attempts)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, false, nil
@@ -2295,7 +2327,9 @@ func (r *campaignProgressRepository) CountHeldLeads(ctx context.Context, campaig
var n int
err := r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM campaign_leads cl
WHERE cl.campaign_id = $1 AND `+liveHold("cl"),
WHERE cl.campaign_id = $1 AND `+liveHold("cl")+`
-- Reached in another lead's thread: nothing is left to wait for.
AND cl.pause_source IS DISTINCT FROM 'cc'`,
campaignID).Scan(&n)
return n, err
}
+14 -9
View File
@@ -1513,6 +1513,14 @@ func (r *contactRepository) buildContactFilter(ctx context.Context, orgID string
}, nil
}
// leadRowJSON is the campaign_leads half of a Leads-list row: the fields read
// together because they come from one lead row.
type leadRowJSON struct {
Sender *string `json:"sender"`
CC []models.CampaignLeadCC `json:"cc"`
Hold *models.LeadHold `json:"hold"`
}
func (r *contactRepository) Search(
ctx context.Context,
orgID string,
@@ -1650,6 +1658,8 @@ func (r *contactRepository) Search(
'lead', (
SELECT json_build_object(
'sender', (SELECT ea.email FROM email_accounts ea WHERE ea.id = hl.email_account_id),
-- Contacts copied on every email to this lead.
'cc', %[5]s,
'hold', CASE WHEN %[4]s THEN json_build_object(
'since', hl.paused_at, 'until', hl.paused_until,
'reason', COALESCE(hl.pause_reason, ''), 'source', COALESCE(hl.pause_source, '')
@@ -1689,7 +1699,7 @@ func (r *contactRepository) Search(
)
FROM campaign_contact_progress p
WHERE p.campaign_id = %[1]s AND p.contact_id = c.id
)`, singleCampaignPlaceholder, config.CampaignSendMaxAttempts, undeliverableClause(singleCampaignPlaceholder), liveHold("hl"))
)`, singleCampaignPlaceholder, config.CampaignSendMaxAttempts, undeliverableClause(singleCampaignPlaceholder), liveHold("hl"), leadCCJSONSQL(singleCampaignPlaceholder))
}
// campaign_count is only ever read by the min/max filters and the
@@ -1828,10 +1838,7 @@ func (r *contactRepository) Search(
Step *string `json:"step"`
// The lead row's own fields, read together because they come
// from one campaign_leads row.
Lead *struct {
Sender *string `json:"sender"`
Hold *models.LeadHold `json:"hold"`
} `json:"lead"`
Lead *leadRowJSON `json:"lead"`
Undeliverable bool `json:"undeliverable"`
}
@@ -1843,10 +1850,7 @@ func (r *contactRepository) Search(
// which the outer query already excludes.
lead := lp.Lead
if lead == nil {
lead = &struct {
Sender *string `json:"sender"`
Hold *models.LeadHold `json:"hold"`
}{}
lead = &leadRowJSON{}
}
status := models.LeadStatusPending
switch {
@@ -1888,6 +1892,7 @@ func (r *contactRepository) Search(
c.CampaignLead = &models.ContactCampaignProgress{
Status: status,
Hold: lead.Hold,
CC: lead.CC,
Sender: sender,
Sent: lp.Sent,
Opened: lp.Opened,
@@ -76,6 +76,12 @@ func (r *contactRepository) ListCampaignStates(ctx context.Context, orgID, conta
}
st.Steps = steps
st.TotalSteps = len(steps)
cc, err := listLeadCC(ctx, r.DB.Pool, st.CampaignID, contactID)
if err != nil {
db.CaptureError(err, "", nil, "ListCampaignStates cc")
return nil, errx.InternalError()
}
st.CC = cc
var sent, replied, bounced, failed bool
var emailSteps, emailSent int
+88
View File
@@ -0,0 +1,88 @@
package tasks
import (
"context"
"strings"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/mailhdr"
)
// campaignCopies resolves who is copied on one send: the campaign's own CC and
// BCC, then the lead's copied contacts. An address that is suppressed, is the
// lead's own, or already appears earlier is left off, so a copy can never
// reach someone the lead's email would not have been allowed to.
func (s *tasksService) campaignCopies(ctx context.Context, orgID uuid.UUID, campaign *models.Campaign, contact *models.Contact) (cc, bcc []string, err error) {
seen := map[string]bool{strings.ToLower(strings.TrimSpace(contact.Email)): true}
keep := func(addr string) (bool, error) {
bare := strings.ToLower(mailhdr.Bare(addr))
if bare == "" || seen[bare] {
return false, nil
}
if s.advanced != nil {
suppressed, _, xerr := s.advanced.ShouldSuppressRecipient(ctx, orgID, bare)
if xerr != nil {
return false, xerr
}
if suppressed {
return false, nil
}
}
seen[bare] = true
return true, nil
}
// A campaign-wide copy that bounced on this campaign is dropped, so one bad
// address cannot keep failing every lead's send.
var wide []string
for _, a := range append(append([]string{}, campaign.CC...), campaign.BCC...) {
if bare := strings.ToLower(mailhdr.Bare(a)); bare != "" {
wide = append(wide, bare)
}
}
bounced, berr := s.campaignProgressRepo.BouncedCopyAddresses(ctx, campaign.ID, wide)
if berr != nil {
return nil, nil, berr
}
for a := range bounced {
seen[a] = true
}
for _, a := range campaign.CC {
ok, kerr := keep(a)
if kerr != nil {
return nil, nil, kerr
}
if ok {
cc = append(cc, a)
}
}
for _, a := range campaign.BCC {
ok, kerr := keep(a)
if kerr != nil {
return nil, nil, kerr
}
if ok {
bcc = append(bcc, a)
}
}
copies, lerr := s.campaignProgressRepo.ListLeadCC(ctx, campaign.ID, contact.ID)
if lerr != nil {
return nil, nil, lerr
}
for _, c := range copies {
// The status already applied suppression, bounces and verification.
if !c.Copied() {
continue
}
bare := strings.ToLower(strings.TrimSpace(c.Email))
if bare == "" || seen[bare] {
continue
}
seen[bare] = true
cc = append(cc, c.Email)
}
return cc, bcc, nil
}
+68
View File
@@ -0,0 +1,68 @@
package tasks
import (
"context"
"reflect"
"strings"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/advanced"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type copiesAdvanced struct {
advanced.Service
suppressed map[string]bool
}
func (f copiesAdvanced) ShouldSuppressRecipient(_ context.Context, _ uuid.UUID, recipient string) (bool, string, *errx.Error) {
return f.suppressed[strings.ToLower(recipient)], "", nil
}
type copiesProgress struct {
repository.CampaignProgressRepository
cc []models.CampaignLeadCC
}
func (f copiesProgress) ListLeadCC(context.Context, uuid.UUID, uuid.UUID) ([]models.CampaignLeadCC, error) {
return f.cc, nil
}
func (f copiesProgress) BouncedCopyAddresses(context.Context, uuid.UUID, []string) (map[string]bool, error) {
return map[string]bool{"refused@acme.test": true}, nil
}
// A copy never reaches someone the lead's own email could not: suppressed
// campaign copies, the lead's own address, repeats and lead copies the status
// already refused are all left off.
func TestCampaignCopiesFiltersEveryCopy(t *testing.T) {
s := &tasksService{
advanced: copiesAdvanced{suppressed: map[string]bool{"gone@acme.test": true}},
campaignProgressRepo: copiesProgress{cc: []models.CampaignLeadCC{
{Email: "jonas@acme.test", Status: models.LeadCCStatusActive},
{Email: "bounced@acme.test", Status: models.LeadCCStatusBounced},
{Email: "Boss@acme.test", Status: models.LeadCCStatusActive},
{Email: "ana@acme.test", Status: models.LeadCCStatusActive},
}},
}
campaign := &models.Campaign{
ID: uuid.New(),
CC: []string{"Boss <boss@acme.test>", "gone@acme.test", "ANA@acme.test"},
BCC: []string{"crm@acme.test", "boss@acme.test", "Refused@acme.test"},
}
contact := &models.Contact{ID: uuid.New(), Email: "ana@acme.test"}
cc, bcc, err := s.campaignCopies(context.Background(), uuid.New(), campaign, contact)
if err != nil {
t.Fatalf("campaignCopies: %v", err)
}
if want := []string{"Boss <boss@acme.test>", "jonas@acme.test"}; !reflect.DeepEqual(cc, want) {
t.Fatalf("cc = %v, want %v", cc, want)
}
if want := []string{"crm@acme.test"}; !reflect.DeepEqual(bcc, want) {
t.Fatalf("bcc = %v, want %v", bcc, want)
}
}
+14 -2
View File
@@ -540,6 +540,18 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) (result *errx
taskRecord.EmailAccountID = account.ID
}
// Who else the email copies, read for an email step before the send is
// reserved. Fail closed: copies that cannot be checked against suppression
// are not sent, and neither is the email without the copies chosen.
copyCC, copyBCC, cerr := s.campaignCopies(ctx, orgID, campaign, contact)
if cerr != nil {
errs.CaptureException(cerr)
s.taskRepo.RecordTaskFailure(ctx, taskID, "Could not read who the email copies", cerr.Error())
s.retryCampaignTickLater(ctx, taskRecord)
executionStatus = "failed"
return errx.InternalError()
}
// STEP 9.4: The conversation this step joins. A follow-up is a nudge on the
// email the contact already has, not a second cold email, so every step
// after their first is threaded onto the last one they received: the
@@ -815,8 +827,8 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) (result *errx
emailMsg := EmailMessage{
From: account.Email,
To: []string{contact.Email},
CC: campaign.CC,
BCC: campaign.BCC,
CC: copyCC,
BCC: copyBCC,
Subject: subject,
BodyHTML: bodyHTML,
BodyPlain: bodyPlain,
+7 -1
View File
@@ -36,7 +36,7 @@ Run `warmblyctl <family> --help` for subcommands and `warmblyctl <family>
| Family | Covers |
|---|---|
| `me` | Identity and granted scopes |
| `campaign` | list, get, create, update, delete, steps, senders, preflight, start, stop, test-email, logs, plan, pause-lead / resume-lead |
| `campaign` | list, get, create, update, delete, steps, senders, preflight, start, stop, test-email, logs, plan, pause-lead / resume-lead, lead-cc / set-lead-cc / lead-cc-suggestions |
| `contact` | list (search), get, lookup, create, update, delete, notes, timeline, import, imports, import-status, import-start, import-cancel, export |
| `mailbox` | list, get, update, delete, auth-check, sync, skip-folders, identity, refresh-identity, behavior, verify, send, warmup-start/pause/resume/stop/status |
| `inbox` | list, count, thread, seen, reply, compose, agent drafts, scheduled sends |
@@ -99,6 +99,12 @@ These commands put real mail on the wire: `campaign start`,
`campaign resume-lead` lifts it. An out-of-office auto-reply already writes
the same hold by itself, across every campaign that contact is a lead of, so
a lead reading `paused` for that reason needs nothing from you.
- To reach two people at one company in ONE thread, copy the second on the
first lead's emails: `campaign set-lead-cc --id <campaign> --contact <lead>
--data '{"contact_ids":["<colleague>"]}'` (at most two;
`campaign lead-cc-suggestions` lists likely colleagues). Do not enrol both as
leads of the same campaign for this: a copied contact's own lead is held
anyway, and resuming that hold sends them a second thread.
- If deliverability analytics show rising bounces or complaints, stop the
campaign first and report; do not push volume into a degrading mailbox.
+8 -1
View File
@@ -72,7 +72,7 @@ gives the arguments and flags. Ids are positional, not flags.
| Command | Covers |
|---|---|
| `status` | one call for "what is happening": mailboxes needing attention, what is sending, what is unread |
| `campaign` | list, view, create, edit, delete, steps, senders, segments, preflight, test, start, stop, logs, plan, pause-lead / resume-lead |
| `campaign` | list, view, create, edit, delete, steps, senders, segments, preflight, test, start, stop, logs, plan, pause-lead / resume-lead, lead-cc / set-lead-cc / lead-cc-suggestions |
| `contact` | list, view, create, edit, delete, lookup, timeline, emails, notes, import, imports, import-status, import-start, import-cancel, export, verify |
| `mailbox` | list, view, edit, check, sync, skip-folders, identity, refresh-identity, behavior, warmup, hold, release, send |
| `inbox` | list, view, thread, read, reply, compose, drafts, scheduled, snooze |
@@ -130,6 +130,13 @@ Everything else is safe to run freely.
recipient answers with an out-of-office auto-reply, in every campaign that
contact is a lead of, so do not also pause a lead that reads `paused` for
that reason.
- To reach two people at one company in ONE thread, copy the second on the
first lead's emails: `warmbly campaign set-lead-cc CAMPAIGN_ID CONTACT_ID
--cc COLLEAGUE_ID` (at most two; `lead-cc-suggestions` lists likely
colleagues). Do not enrol both as leads of the same campaign for this: a
copied contact's own lead is held anyway, and `resume-lead` on that hold
sends them a second thread. Every copy is a recipient who did not ask for
the email, so keep it to small, personal campaigns.
- If deliverability shows rising bounces or complaints, stop the campaign and
report. Do not push volume into a degrading mailbox.
- To check where copy lands before a launch, `warmbly placement test --mailbox
+5 -11
View File
@@ -21,7 +21,7 @@ import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence";
import type { DraftMeta } from "./serverDraft";
import EmailContentEditor from "@/components/app/campaigns/sequences/EmailContentEditor";
import { useSegments } from "@/lib/api/hooks/app/segments";
import { CheckSquare } from "@/components/ui/check-square";
import { Checkbox } from "@/components/ui/checkbox";
import TagSelector from "@/components/app/popup/select/TagSelector";
import ScrollStrip from "@/components/ui/scroll-strip";
import { DateTimePicker } from "@/components/ui/DateTimePicker";
@@ -148,20 +148,14 @@ export function LeadsStep({
{shown.map((l) => {
const on = picked.has(l.id);
return (
<button
<label
key={l.id}
type="button"
role="checkbox"
aria-checked={on}
onClick={() => toggle(l.id)}
className={cn(
"text-left rounded-md border px-3 py-2.5 flex items-start gap-2.5 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-sky-100",
"cursor-pointer select-none rounded-md border px-3 py-2.5 flex items-start gap-2.5 transition-colors",
on ? "border-sky-400 bg-sky-50/60 ring-1 ring-inset ring-sky-400" : "border-slate-200 hover:border-slate-300 hover:bg-slate-50",
)}
>
<span className="mt-[3px]">
<CheckSquare checked={on} />
</span>
<Checkbox className="mt-[3px]" checked={on} onChange={() => toggle(l.id)} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full shrink-0" style={{ background: l.color || "#0284c7" }} />
@@ -172,7 +166,7 @@ export function LeadsStep({
{l.description ? ` · ${l.description}` : ""}
</span>
</span>
</button>
</label>
);
})}
{!q && (
@@ -69,6 +69,7 @@ import type { CampaignLeadCounts } from "@/lib/api/models/app/contacts/SearchCon
import ContactsEditBulk from "./ContactsEditBulk";
import PauseLeadDialog from "./PauseLeadDialog";
import { useResumeLead } from "@/lib/api/hooks/app/campaigns/useLeadHold";
import { CC_RESUME_CONFIRM } from "@/lib/leadHold";
import { selectionOf } from "@/lib/api/models/app/contacts/ContactSelection";
import type ContactSelection from "@/lib/api/models/app/contacts/ContactSelection";
import * as rowSelection from "./selection";
@@ -549,22 +550,26 @@ export default function ContactsTable({
const [pauseTarget, setPauseTarget] = React.useState<{ id: string; name: string } | null>(null);
const resumeLead = useResumeLead();
const resumeOne = React.useCallback(
async (contactId: string) => {
(contactId: string, copied?: boolean) => {
if (!current_campaign) return;
try {
await toast.promise(
resumeLead.mutateAsync({ campaignId: current_campaign.id, contactId }),
{
loading: "Resuming lead…",
success: "Lead resumed",
error: (err: AppError) => buildError(err),
},
);
} catch {
/* toast.promise already surfaced it */
}
const run = async () => {
try {
await toast.promise(
resumeLead.mutateAsync({ campaignId: current_campaign.id, contactId }),
{
loading: "Resuming lead…",
success: "Lead resumed",
error: (err: AppError) => buildError(err),
},
);
} catch {
/* toast.promise already surfaced it */
}
};
if (copied) confirm.show(CC_RESUME_CONFIRM, run);
else void run();
},
[current_campaign, resumeLead],
[current_campaign, resumeLead, confirm],
);
// Leads-view scope chips write straight into the search request, so the
@@ -1185,7 +1190,7 @@ function ContactsTableBody({
// member without campaign write access, which takes the control off the
// row rather than offering one that fails.
onPauseLead?: (id: string, name: string) => void;
onResumeLead?: (id: string) => void;
onResumeLead?: (id: string, copied?: boolean) => void;
emptyTitle: string;
emptyBody: string;
emptyCta: React.ReactNode;
@@ -1405,7 +1410,7 @@ function ContactsTableBody({
type="button"
aria-label="Resume lead"
title={`${holdSummary(lead.hold)}. Resume now`}
onClick={() => onResumeLead(c.id)}
onClick={() => onResumeLead(c.id, lead.hold?.source === "cc")}
className="size-6 rounded text-violet-500 hover:text-violet-700 hover:bg-violet-50 flex items-center justify-center transition-colors"
>
<PlayIcon className="w-3 h-3" />
@@ -5,6 +5,7 @@ import { Loader2Icon, PauseIcon, PlayIcon } from "lucide-react";
import toast from "react-hot-toast";
import PauseLeadDialog from "./PauseLeadDialog";
import { useResumeLead } from "@/lib/api/hooks/app/campaigns/useLeadHold";
import { useConfirm } from "@/hooks/context/confirm";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
@@ -40,14 +41,18 @@ export function ResumeLeadButton({
label = "Resume",
disabled = false,
onBusyChange,
confirmText,
}: {
campaignId: string;
contactId: string;
label?: string;
disabled?: boolean;
onBusyChange?: (busy: boolean) => void;
// Asked first when resuming has a consequence worth a second look.
confirmText?: string;
}) {
const resume = useResumeLead();
const confirm = useConfirm();
async function run() {
onBusyChange?.(true);
try {
@@ -65,7 +70,7 @@ export function ResumeLeadButton({
return (
<button
type="button"
onClick={run}
onClick={() => (confirmText ? confirm.show(confirmText, run) : void run())}
disabled={disabled || resume.isPending}
title="Lift the pause now; the flow picks up where it stopped"
className="h-6 px-2 rounded-md bg-white border border-violet-200 text-[11px] font-medium text-violet-700 hover:bg-violet-100 inline-flex items-center gap-1 transition-colors disabled:opacity-60 shrink-0"
@@ -156,6 +156,16 @@ const nameColumn: ContactColumn = {
<MailIcon className="w-2.5 h-2.5 shrink-0" />
<span className="truncate" {...clippedTitle}>{c.email}</span>
<VerificationBadge contact={c} />
{!!c.campaign_lead?.cc?.length && (
<span
className="inline-flex items-center h-3.5 px-1 shrink-0 rounded bg-sky-50 text-sky-700 font-sans text-[9.5px] font-medium"
title={`CC: ${c.campaign_lead.cc
.map((x) => (x.status === "active" ? x.email : `${x.email} (${x.status}, left off)`))
.join(", ")}`}
>
CC {c.campaign_lead.cc.length}
</span>
)}
</div>
</div>
</div>
@@ -60,8 +60,9 @@ import { holdSummary } from "@/lib/api/models/app/contacts/Contact";
import type { LeadHold } from "@/lib/api/models/app/contacts/Contact";
import LeadStatusPill from "@/components/app/contacts/LeadStatusPill";
import { usePauseLead } from "@/lib/api/hooks/app/campaigns/useLeadHold";
import { leadCanBePaused } from "@/lib/leadHold";
import { CC_RESUME_CONFIRM, leadCanBePaused } from "@/lib/leadHold";
import { PauseLeadButton, ResumeLeadButton } from "@/components/app/contacts/LeadHoldButtons";
import LeadCCBar from "./LeadCCBar";
import toast from "react-hot-toast";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
@@ -385,6 +386,17 @@ function CampaignCard({
leadCanBePaused(state) && <PauseBar state={state} contactId={contactId} contactName={contactName} />
)}
{/* A lead reached in someone else's thread sends nothing to copy anyone on. */}
{state.hold?.source !== "cc" && (
<LeadCCBar
campaignId={state.campaign_id}
contactId={contactId}
contactName={contactName}
cc={state.cc ?? []}
sending={!state.ended_reason && state.campaign_status !== "completed"}
/>
)}
<AnimatePresence initial={false}>
{open && (
<motion.div
@@ -446,7 +458,8 @@ function HoldBar({
<ResumeLeadButton
campaignId={campaignId}
contactId={contactId}
label="Resume now"
label={hold.source === "cc" ? "Send their own too" : "Resume now"}
confirmText={hold.source === "cc" ? CC_RESUME_CONFIRM : undefined}
disabled={pause.isPending}
onBusyChange={setResuming}
/>
@@ -0,0 +1,280 @@
// LeadCCBar: the contacts copied on every email one campaign sends this lead,
// so colleagues at one company share a single thread (issue #731). Sits under
// the campaign card's header, next to the hold and pause strips.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Loader2Icon, PlusIcon, SearchIcon, UsersIcon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import useClickOutside from "@/hooks/useClickOutside";
import useFlipPlacement from "@/hooks/useFlipPlacement";
import useDebouncedValue from "@/hooks/useDebouncedValue";
import { useWriteGuard } from "@/hooks/usePermission";
import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts";
import { useLeadCCSuggestions, useSetLeadCC } from "@/lib/api/hooks/app/campaigns/useLeadCC";
import { LEAD_CC_MAX, leadCCName, type LeadCC, type LeadCCStatus } from "@/lib/api/models/app/contacts/Contact";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { cn } from "@/lib/utils";
const STATUS_NOTE: Record<Exclude<LeadCCStatus, "active">, string> = {
unsubscribed: "Unsubscribed, so left off the next email",
bounced: "Bounced, so left off the next email",
undeliverable: "Failed verification, so left off the next email",
};
interface Candidate {
id: string;
email: string;
name: string;
company?: string;
tag?: string;
}
export default function LeadCCBar({
campaignId,
contactId,
contactName,
cc,
sending,
}: {
campaignId: string;
contactId: string;
contactName: string;
cc: LeadCC[];
// The lead still has emails to send; copying anyone on a finished flow does nothing.
sending: boolean;
}) {
const write = useWriteGuard("MANAGE_CAMPAIGNS");
const setCC = useSetLeadCC();
const [open, setOpen] = React.useState(false);
if (cc.length === 0 && (!write.allowed || !sending)) return null;
async function save(ids: string[], success: string) {
try {
await toast.promise(setCC.mutateAsync({ campaignId, contactId, contactIds: ids }), {
loading: "Saving…",
success,
error: (err: AppError) => buildError(err),
});
} catch {
/* toast.promise already surfaced it */
}
}
const ids = cc.map((c) => c.contact_id);
const canAdd = write.allowed && sending && cc.length < LEAD_CC_MAX;
return (
<div className="px-3 py-2 border-t border-slate-100 flex items-center gap-1.5 flex-wrap">
<span
className="text-[10px] uppercase tracking-[0.14em] font-semibold text-slate-400 mr-0.5"
title={`Copied on every email to ${contactName} in this campaign. A reply from anyone on the thread counts as ${contactName}'s reply.`}
>
CC
</span>
{cc.map((c) => {
const note = c.status === "active" ? undefined : STATUS_NOTE[c.status];
return (
<span
key={c.contact_id}
title={note ? `${c.email} · ${note}` : c.email}
className={cn(
"inline-flex items-center gap-1 h-5 pl-1.5 rounded bg-slate-100 text-[11px] max-w-[14rem]",
write.allowed ? "pr-0.5" : "pr-1.5",
note ? "text-slate-400" : "text-slate-700",
)}
>
<span className={cn("truncate", note && "line-through")}>{leadCCName(c)}</span>
{write.allowed && (
<button
type="button"
aria-label={`Stop copying ${leadCCName(c)}`}
disabled={setCC.isPending}
onClick={() => save(ids.filter((id) => id !== c.contact_id), "No longer copied")}
className="size-4 inline-flex items-center justify-center rounded text-slate-400 hover:text-slate-700 hover:bg-slate-200 disabled:opacity-50"
>
<XIcon className="w-2.5 h-2.5" />
</button>
)}
</span>
);
})}
{canAdd && (
<CCPicker
open={open}
setOpen={setOpen}
campaignId={campaignId}
contactId={contactId}
contactName={contactName}
exclude={ids}
busy={setCC.isPending}
empty={cc.length === 0}
onPick={(id) => {
setOpen(false);
void save([...ids, id], "Copied on every email to this lead");
}}
/>
)}
</div>
);
}
function CCPicker({
open,
setOpen,
campaignId,
contactId,
contactName,
exclude,
busy,
empty,
onPick,
}: {
open: boolean;
setOpen: (v: boolean) => void;
campaignId: string;
contactId: string;
contactName: string;
exclude: string[];
busy: boolean;
empty: boolean;
onPick: (id: string) => void;
}) {
const ref = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const [query, setQuery] = React.useState("");
useClickOutside(ref, () => setOpen(false));
const placement = useFlipPlacement(triggerRef, open, 300);
const q = useDebouncedValue(query.trim(), 250);
const suggestions = useLeadCCSuggestions(campaignId, contactId, open);
const search = useSearchContacts({
options: {
query: q,
custom_field_filters: [],
campaign_ids: [],
sort_by: "updated_at",
reverse: false,
},
limit: 8,
enabled: open && q.length > 0,
keepPrevious: true,
});
const skip = React.useMemo(() => new Set([contactId, ...exclude]), [contactId, exclude]);
const candidates: Candidate[] = React.useMemo(() => {
if (q.length > 0) {
return (search.contacts ?? [])
.filter((c) => !skip.has(c.id))
.map((c) => ({
id: c.id,
email: c.email,
name: `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() || c.email,
company: c.company,
}));
}
return (suggestions.data?.data ?? [])
.filter((s) => !skip.has(s.contact_id))
.map((s) => ({
id: s.contact_id,
email: s.email,
name: leadCCName(s),
company: s.company,
tag: s.reason === "company" ? "Same company" : "Same domain",
}));
}, [q, search.contacts, suggestions.data, skip]);
const loading = q.length > 0 ? search.isFetching && !search.contacts : suggestions.isLoading;
return (
<div
ref={ref}
className="relative"
// On the wrapper, so Escape closes the picker from any focused child.
onKeyDown={(e) => {
if (e.key === "Escape" && open) {
e.stopPropagation();
setOpen(false);
}
}}
>
<button
ref={triggerRef}
type="button"
disabled={busy}
onClick={() => setOpen(!open)}
title={`Copy a colleague on every email to ${contactName} in this campaign`}
className="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[11px] font-medium border border-dashed border-slate-300 text-slate-500 hover:border-slate-400 hover:text-slate-700 disabled:opacity-50"
>
<PlusIcon className="w-2.5 h-2.5" />
{empty ? "CC a colleague" : "Add"}
</button>
<AnimatePresence>
{open && (
<motion.div
data-floating
initial={{ opacity: 0, y: placement === "top" ? 4 : -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: placement === "top" ? 4 : -4 }}
transition={{ duration: 0.12 }}
className={cn(
"absolute left-0 z-30 w-72 rounded-md border border-slate-200 bg-white shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)] overflow-hidden",
placement === "top" ? "bottom-full mb-1" : "top-full mt-1",
)}
>
<div className="px-2 py-1.5 border-b border-slate-200 flex items-center gap-1.5">
<SearchIcon className="w-3 h-3 text-slate-400 shrink-0" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search contacts…"
autoFocus
className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none"
/>
</div>
<div className="max-h-56 overflow-y-auto py-1">
{loading ? (
<div className="px-3 py-3 flex justify-center">
<Loader2Icon className="w-3.5 h-3.5 animate-spin text-slate-400" />
</div>
) : candidates.length === 0 ? (
<div className="px-3 py-3 text-[11.5px] text-slate-400 text-center">
{q.length > 0 ? "No matching contacts." : "No colleagues found. Search for a contact."}
</div>
) : (
candidates.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onPick(c.id)}
className="w-full px-2.5 py-1.5 flex items-center gap-2 text-left hover:bg-slate-100 transition-colors"
>
<UsersIcon className="w-3 h-3 text-slate-400 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block text-[12px] text-slate-800 truncate">{c.name}</span>
<span className="block text-[11px] text-slate-400 truncate">
{c.email}
{c.company ? ` · ${c.company}` : ""}
</span>
</span>
{c.tag && (
<span className="text-[10px] text-sky-700 bg-sky-50 rounded px-1 py-0.5 shrink-0">
{c.tag}
</span>
)}
</button>
))
)}
</div>
<div className="px-2.5 py-1.5 border-t border-slate-100 text-[10.5px] leading-snug text-slate-400">
Copied on every email to {contactName} in this campaign, up to {LEAD_CC_MAX}. A reply from
anyone counts as {contactName}'s reply.
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -103,7 +103,6 @@ export default function SeedChooser({
)}
>
<Checkbox
tone="slate"
checked={!blocker && chosen.has(s.email_account_id)}
disabled={!!blocker}
onChange={() => toggle(s.email_account_id)}
@@ -46,7 +46,7 @@ import useContactCampaignStates from "@/lib/api/hooks/app/contacts/useContactCam
import type ContactCampaignState from "@/lib/api/models/app/contacts/ContactCampaignState";
import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign";
import { holdSummary } from "@/lib/api/models/app/contacts/Contact";
import { leadCanBePaused } from "@/lib/leadHold";
import { CC_RESUME_CONFIRM, leadCanBePaused } from "@/lib/leadHold";
import { usePermission } from "@/hooks/usePermission";
import { PauseLeadButton, ResumeLeadButton } from "@/components/app/contacts/LeadHoldButtons";
import LeadStatusPill from "@/components/app/contacts/LeadStatusPill";
@@ -372,7 +372,11 @@ function CampaignsSection({
{line.text}
</span>
{canWrite && s.hold ? (
<ResumeLeadButton campaignId={s.campaign_id} contactId={contactId} />
<ResumeLeadButton
campaignId={s.campaign_id}
contactId={contactId}
confirmText={s.hold.source === "cc" ? CC_RESUME_CONFIRM : undefined}
/>
) : canWrite && leadCanBePaused(s) ? (
<PauseLeadButton
campaign={{ id: s.campaign_id, name: s.campaign_name }}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,635 @@
// The scope rail's sections fold and their rows can be hidden.
//
// Both are per-browser preferences that live in the persisted store, so what is
// pinned here is the part that is easy to get wrong: the fold survives, a
// folded section still shows the scope you are on, hiding a row only takes it
// off the rail (the active scope never disappears), and a stored value that is
// not what the setters would have written cannot reach the screen.
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { useAppStore } from "@/stores";
import { applyRailOrder, sanitizeUniboxRailHidden, sanitizeUniboxRailOrder } from "@/stores/slices/uiSlice";
import { ScopeRail, type UniboxScope } from "./ScopeRail";
const overview = vi.hoisted(() => ({
data: {
total: 12,
unread: 3,
today: 0,
week: 0,
snoozed: 0,
awaiting_reply: 0,
automated: 0,
automated_unread: 0,
awaiting_agent_draft: 0,
scheduled_pending: 0,
scheduled_pending_max: 100,
folders: [
{ folder: "inbox", unread: 3, total: 9 },
{ folder: "spam", unread: 0, total: 1 },
],
mailboxes: [{ id: "m1", email: "me@example.com", name: "Me", unread: 2, total: 5 }],
tags: [],
categories: [{ id: "c1", title: "Interested", color: "#0ea5e9", unread: 1, total: 4 }],
},
}));
vi.mock("@/lib/api/hooks/app/unibox/useUniboxOverview", () => ({
default: () => ({ data: overview.data, isPending: false }),
}));
vi.mock("@/lib/api/hooks/app/unibox/useMarkSeen", () => ({
default: () => ({ mutate: () => {} }),
}));
vi.mock("@/components/app/unibox/compose/ComposeDraftsItem", () => ({
default: () => null,
}));
// Exit animations never finish in jsdom; a closed menu unmounts at once instead.
vi.mock("framer-motion", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// The count-up tween needs a real animation frame; a plain number is enough here.
vi.mock("@/components/ui/AnimatedNumber", () => ({
default: ({ value }: { value: number }) => <>{value}</>,
}));
// The toggle's name grows by the dot's screen-reader text when a highlighted
// count is folded away, so match on how it starts.
const MAIL_TOGGLE = /^Mail(?!boxes| section)/;
const DOT = /highlighted count folded away/;
// The header's always-visible pencil.
function startEditing(section: "Mail" | "Views") {
fireEvent.click(screen.getByRole("button", { name: `Edit ${section} rows` }));
}
function mountRail(scope: UniboxScope = { kind: "all" }) {
return render(<ScopeRail scope={scope} onChange={() => {}} />);
}
beforeEach(() => {
useAppStore.setState({
uniboxRailFolded: {},
uniboxRailHidden: [],
uniboxRailOrder: {},
uniboxRailSectionOrder: [],
});
});
afterEach(() => {
cleanup();
});
describe("ScopeRail sections", () => {
it("starts expanded with nothing hidden and a Mail header over the mail rows", () => {
mountRail();
expect(screen.getByRole("button", { name: "Mail", expanded: true })).toBeTruthy();
expect(screen.getByRole("button", { name: "Views", expanded: true })).toBeTruthy();
for (const label of ["All mail", "Inbox", "Spam", "Trash", "Scheduled"]) {
expect(screen.getByText(label)).toBeTruthy();
}
});
it("folds a section, hides its rows and remembers it in the persisted store", () => {
mountRail();
fireEvent.click(screen.getByRole("button", { name: MAIL_TOGGLE }));
expect(useAppStore.getState().uniboxRailFolded.mail).toBe(true);
expect(screen.getByRole("button", { name: MAIL_TOGGLE, expanded: false })).toBeTruthy();
expect(screen.queryByText("Spam")).toBeNull();
expect(screen.queryByText("Inbox")).toBeNull();
// Part of what the store writes to storage, not just in-memory state.
const persisted = useAppStore.persist.getOptions().partialize?.(useAppStore.getState()) as
| { uniboxRailFolded?: Record<string, boolean> }
| undefined;
expect(persisted?.uniboxRailFolded).toEqual({ mail: true });
fireEvent.click(screen.getByRole("button", { name: MAIL_TOGGLE }));
expect(screen.getByText("Spam")).toBeTruthy();
});
it("keeps the row you are on when the section is folded", () => {
useAppStore.setState({ uniboxRailFolded: { mail: true } });
mountRail({ kind: "folder", folder: "spam" });
expect(screen.getByText("Spam")).toBeTruthy();
expect(screen.queryByText("Inbox")).toBeNull();
});
it("keeps the active mailbox visible in a folded Mailboxes section", () => {
useAppStore.setState({ uniboxRailFolded: { mailboxes: true } });
mountRail({ kind: "mailbox", mailboxId: "m1" });
expect(screen.getByText("me@example.com")).toBeTruthy();
cleanup();
mountRail();
expect(screen.queryByText("me@example.com")).toBeNull();
});
it("flags a highlighted count folded out of sight with a dot, and drops it when the section opens", () => {
useAppStore.setState({ uniboxRailFolded: { mail: true } });
mountRail({ kind: "folder", folder: "spam" });
expect(screen.getByRole("button", { name: "Mail, highlighted count folded away" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: MAIL_TOGGLE }));
expect(screen.queryByText(DOT)).toBeNull();
});
it("says highlighted count, not unread, because Scheduled raises the dot too", () => {
const saved = overview.data;
overview.data = {
...saved,
unread: 0,
scheduled_pending: 5,
folders: [
{ folder: "inbox", unread: 0, total: 9 },
{ folder: "spam", unread: 0, total: 1 },
],
};
try {
useAppStore.setState({ uniboxRailFolded: { mail: true } });
mountRail({ kind: "folder", folder: "spam" });
expect(screen.getByText(DOT)).toBeTruthy();
expect(screen.queryByText(/unread/i)).toBeNull();
} finally {
overview.data = saved;
}
});
it("does not raise the dot for rows the user hid", () => {
useAppStore.setState({
uniboxRailFolded: { mail: true },
uniboxRailHidden: ["folder:inbox", "unread"],
});
mountRail({ kind: "folder", folder: "spam" });
expect(screen.queryByText(DOT)).toBeNull();
});
it("marks the row you are on with aria-current", () => {
mountRail({ kind: "unread" });
const current = document.querySelectorAll("[aria-current]");
expect(current).toHaveLength(1);
expect(current[0].textContent).toContain("Unread");
});
it("folds Views on its own", () => {
mountRail();
expect(screen.getByText("Hot leads")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Views" }));
expect(useAppStore.getState().uniboxRailFolded.views).toBe(true);
expect(screen.queryByText("Hot leads")).toBeNull();
// Another section is untouched.
expect(screen.getByText("Inbox")).toBeTruthy();
});
});
describe("ScopeRail edit mode", () => {
it("hides a row through Edit, uncheck, Done", () => {
mountRail();
startEditing("Mail");
// Every row is a checkbox, all checked to start with.
const spam = screen.getByRole("checkbox", { name: "Spam" });
expect(spam.getAttribute("aria-checked")).toBe("true");
fireEvent.click(spam);
expect(screen.getByRole("checkbox", { name: "Spam" }).getAttribute("aria-checked")).toBe("false");
// Still listed while editing, so it can be turned back on.
expect(screen.getByText("Spam")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Done editing Mail" }));
expect(useAppStore.getState().uniboxRailHidden).toEqual(["folder:spam"]);
expect(screen.queryByText("Spam")).toBeNull();
expect(screen.getByText("Trash")).toBeTruthy();
});
it("the pencil does not fold the section", () => {
mountRail();
fireEvent.click(screen.getByRole("button", { name: "Edit Mail rows" }));
expect(useAppStore.getState().uniboxRailFolded.mail).toBeUndefined();
expect(screen.getByText("Spam")).toBeTruthy();
// One click enters edit mode: no menu in between.
expect(screen.queryByRole("menuitem")).toBeNull();
expect(screen.getByRole("button", { name: "Done editing Mail" })).toBeTruthy();
});
it("counts hidden rows beside the pencil, open or folded, and drops the count when they are shown", () => {
mountRail();
expect(screen.queryByText(/\d+ hidden/)).toBeNull();
startEditing("Mail");
fireEvent.click(screen.getByRole("checkbox", { name: "Spam" }));
fireEvent.click(screen.getByRole("checkbox", { name: "Trash" }));
// Not shown while editing: every row is on screen then.
expect(screen.queryByText("2 hidden")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Done editing Mail" }));
expect(screen.getByText("2 hidden")).toBeTruthy();
// Tabbing to the pencil hears the number too.
expect(
screen.getByRole("button", { name: "Edit Mail rows", description: "2 hidden" }),
).toBeTruthy();
// Views has none of its own.
expect(screen.queryByText("1 hidden")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: MAIL_TOGGLE }));
expect(screen.getByText("2 hidden")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: MAIL_TOGGLE }));
startEditing("Mail");
fireEvent.click(screen.getByRole("checkbox", { name: "Spam" }));
fireEvent.click(screen.getByRole("checkbox", { name: "Trash" }));
fireEvent.click(screen.getByRole("button", { name: "Done editing Mail" }));
expect(screen.queryByText(/\d+ hidden/)).toBeNull();
});
it("does not navigate while editing and drops the folder menu", () => {
const onChange = vi.fn();
render(<ScopeRail scope={{ kind: "all" }} onChange={onChange} />);
expect(screen.getByLabelText("Inbox folder actions")).toBeTruthy();
startEditing("Mail");
fireEvent.click(screen.getByRole("checkbox", { name: "Inbox" }));
expect(onChange).not.toHaveBeenCalled();
expect(screen.queryByLabelText("Inbox folder actions")).toBeNull();
});
it("shows hidden rows and unfolds the section while editing", () => {
useAppStore.setState({ uniboxRailFolded: { mail: true }, uniboxRailHidden: ["folder:trash"] });
mountRail();
expect(screen.queryByText("Trash")).toBeNull();
startEditing("Mail");
expect(screen.getByRole("checkbox", { name: "Trash" }).getAttribute("aria-checked")).toBe("false");
expect(screen.getByRole("checkbox", { name: "Inbox" })).toBeTruthy();
});
it("ends edit mode on Escape", () => {
mountRail();
startEditing("Mail");
expect(screen.getAllByRole("checkbox").length).toBeGreaterThan(0);
fireEvent.keyDown(screen.getByRole("checkbox", { name: "Inbox" }), { key: "Escape" });
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
expect(screen.getByRole("button", { name: "Edit Mail rows" })).toBeTruthy();
});
it("holds the fold while editing, so Done never folds by surprise", () => {
mountRail();
startEditing("Mail");
const toggle = screen.getByRole("button", { name: MAIL_TOGGLE });
expect((toggle as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(toggle);
expect(useAppStore.getState().uniboxRailFolded.mail).toBeUndefined();
fireEvent.click(screen.getByRole("button", { name: "Done editing Mail" }));
expect(screen.getByText("Spam")).toBeTruthy();
});
it("moves focus into the checkboxes and back to the options button", () => {
mountRail();
startEditing("Mail");
expect(document.activeElement).toBe(screen.getByRole("checkbox", { name: "All mail" }));
fireEvent.keyDown(document.activeElement as Element, { key: "Escape" });
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Edit Mail rows" }));
startEditing("Mail");
fireEvent.click(screen.getByRole("button", { name: "Done editing Mail" }));
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Edit Mail rows" }));
});
it("edits Views separately from Mail", () => {
mountRail();
startEditing("Views");
fireEvent.click(screen.getByRole("checkbox", { name: "Hot leads" }));
fireEvent.click(screen.getByRole("button", { name: "Done editing Views" }));
expect(useAppStore.getState().uniboxRailHidden).toEqual(["view:hot"]);
expect(screen.queryByText("Hot leads")).toBeNull();
expect(screen.getByText("Follow up")).toBeTruthy();
});
it("keeps the header, and so Edit, when every row is hidden", () => {
useAppStore.setState({
uniboxRailHidden: [
"all", "folder:inbox", "unread", "awaiting", "agent_drafts", "snoozed",
"folder:drafts", "folder:sent", "scheduled", "folder:archive", "folder:spam", "folder:trash",
],
});
mountRail({ kind: "mailbox", mailboxId: "m1" });
expect(screen.queryByText("Inbox")).toBeNull();
expect(screen.getByRole("button", { name: "Edit Mail rows" })).toBeTruthy();
});
});
describe("hidden rows", () => {
it("never hide the active scope", () => {
useAppStore.setState({ uniboxRailHidden: ["folder:spam"] });
mountRail({ kind: "folder", folder: "spam" });
expect(screen.getByText("Spam")).toBeTruthy();
cleanup();
mountRail({ kind: "all" });
expect(screen.queryByText("Spam")).toBeNull();
});
});
describe("sanitizeUniboxRailHidden", () => {
it("drops anything that is not a string and removes duplicates", () => {
expect(sanitizeUniboxRailHidden(["folder:spam", 4, null, {}, "folder:spam", "view:hot"])).toEqual([
"folder:spam",
"view:hot",
]);
expect(sanitizeUniboxRailHidden("folder:spam")).toEqual([]);
expect(sanitizeUniboxRailHidden(undefined)).toEqual([]);
});
it("runs on rehydration, where the setters are bypassed", async () => {
const original = useAppStore.persist.getOptions().storage;
useAppStore.persist.setOptions({
storage: {
getItem: () =>
({
state: {
uniboxRailHidden: ["folder:spam", 7, "folder:spam"],
uniboxRailFolded: { mail: true, views: "yes" },
},
}) as never,
setItem: () => {},
removeItem: () => {},
},
});
try {
await useAppStore.persist.rehydrate();
expect(useAppStore.getState().uniboxRailHidden).toEqual(["folder:spam"]);
expect(useAppStore.getState().uniboxRailFolded).toEqual({ mail: true });
} finally {
useAppStore.persist.setOptions({ storage: original });
}
});
});
const MAIL_DEFAULT = [
"all", "folder:inbox", "unread", "awaiting", "agent_drafts", "snoozed",
"folder:drafts", "folder:sent", "scheduled", "folder:archive", "folder:spam", "folder:trash",
];
// The rail row element around a label, as the keyboard sees it.
const rowOf = (label: string) => screen.getByText(label).closest("[data-rail-row]") as HTMLElement;
// Headers in the order they are on screen.
const headerOrder = () =>
screen.getAllByRole("button", { expanded: true }).map((b) => b.textContent?.replace(/\d+$/, ""));
describe("row order", () => {
it("moves a row with the grip's arrow keys while editing, and says where it went", () => {
mountRail();
startEditing("Mail");
fireEvent.keyDown(screen.getByRole("button", { name: /^Move Spam/ }), { key: "ArrowUp" });
const order = useAppStore.getState().uniboxRailOrder.mail;
expect(order.indexOf("folder:spam")).toBe(order.indexOf("folder:archive") - 1);
expect(screen.getByText("Spam moved to position 10 of 12")).toBeTruthy();
// Checkboxes follow the new order.
const names = screen.getAllByRole("checkbox").map((c) => c.textContent);
expect(names.indexOf("Spam")).toBeLessThan(names.indexOf("Archive"));
});
it("moves a row with Alt+arrow outside edit mode, stepping over a hidden neighbour", () => {
useAppStore.setState({ uniboxRailHidden: ["unread"] });
mountRail();
fireEvent.keyDown(rowOf("Inbox"), { key: "ArrowDown", altKey: true });
expect(useAppStore.getState().uniboxRailOrder.mail.slice(0, 4)).toEqual([
"all", "unread", "awaiting", "folder:inbox",
]);
});
it("stores nothing once a row is moved back to where it started", () => {
mountRail();
fireEvent.keyDown(rowOf("Inbox"), { key: "ArrowDown", altKey: true });
expect(useAppStore.getState().uniboxRailOrder.mail).toBeDefined();
fireEvent.keyDown(rowOf("Inbox"), { key: "ArrowUp", altKey: true });
expect(useAppStore.getState().uniboxRailOrder.mail).toBeUndefined();
});
it("renders a stored order", () => {
useAppStore.setState({ uniboxRailOrder: { mail: ["folder:trash", ...MAIL_DEFAULT.slice(0, -1)] } });
mountRail();
const rows = Array.from(document.querySelectorAll("[data-rail-row]")).map((r) => r.textContent);
expect(rows[0]).toContain("Trash");
});
it("resets order and hidden rows from the edit footer", () => {
useAppStore.setState({
uniboxRailHidden: ["folder:spam", "view:hot"],
uniboxRailOrder: { mail: ["folder:trash", ...MAIL_DEFAULT.slice(0, -1)] },
});
mountRail();
startEditing("Mail");
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
expect(useAppStore.getState().uniboxRailOrder.mail).toBeUndefined();
// Only this section's rows come back.
expect(useAppStore.getState().uniboxRailHidden).toEqual(["view:hot"]);
expect((screen.getByRole("button", { name: "Reset" }) as HTMLButtonElement).disabled).toBe(true);
});
});
describe("row menu", () => {
it("hides a row from its menu", () => {
mountRail();
fireEvent.click(screen.getByLabelText("Spam folder actions"));
fireEvent.click(screen.getByRole("menuitem", { name: "Hide from rail" }));
expect(useAppStore.getState().uniboxRailHidden).toEqual(["folder:spam"]);
expect(screen.queryByText("Spam")).toBeNull();
});
it("opens the same menu on right-click, without opening the scope", () => {
const onChange = vi.fn();
render(<ScopeRail scope={{ kind: "all" }} onChange={onChange} />);
fireEvent.contextMenu(rowOf("Unread"), { clientX: 40, clientY: 80 });
expect(screen.getByRole("menuitem", { name: /Move up/ })).toBeTruthy();
expect(onChange).not.toHaveBeenCalled();
});
it("disables moves the row cannot make", () => {
mountRail();
fireEvent.click(screen.getByLabelText("All mail actions"));
expect((screen.getByRole("menuitem", { name: /Move up/ }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("menuitem", { name: /Move down/ }) as HTMLButtonElement).disabled).toBe(false);
});
it("walks its items with the arrow keys", async () => {
mountRail();
fireEvent.click(screen.getByLabelText("Inbox folder actions"));
const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(document.activeElement?.textContent).toBe("Mark all as read");
fireEvent.keyDown(document.activeElement as Element, { key: "ArrowDown" });
expect(document.activeElement?.textContent).toContain("Move up");
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
expect(document.activeElement?.textContent).toContain("Edit Mail rows");
});
});
describe("section menu", () => {
it("moves a section and remembers it", () => {
mountRail();
expect(headerOrder().slice(0, 2)).toEqual(["Mail", "Views"]);
fireEvent.click(screen.getByLabelText("Mail section options"));
fireEvent.click(screen.getByRole("menuitem", { name: "Move section down" }));
expect(useAppStore.getState().uniboxRailSectionOrder.slice(0, 2)).toEqual(["views", "mail"]);
expect(headerOrder().slice(0, 2)).toEqual(["Views", "Mail"]);
});
it("folds every other section", () => {
mountRail();
fireEvent.click(screen.getByLabelText("Views section options"));
fireEvent.click(screen.getByRole("menuitem", { name: "Fold other sections" }));
const folded = useAppStore.getState().uniboxRailFolded;
expect(folded).toMatchObject({ mail: true, views: false, mailboxes: true, labels: true });
expect(screen.getByText("Hot leads")).toBeTruthy();
expect(screen.queryByText("Inbox")).toBeNull();
});
it("offers to show the hidden rows", () => {
useAppStore.setState({ uniboxRailHidden: ["folder:spam", "folder:trash"] });
mountRail();
fireEvent.click(screen.getByLabelText("Mail section options"));
fireEvent.click(screen.getByRole("menuitem", { name: "Show 2 hidden rows" }));
expect(useAppStore.getState().uniboxRailHidden).toEqual([]);
expect(screen.getByText("Spam")).toBeTruthy();
});
it("starts editing from the menu", () => {
mountRail();
fireEvent.click(screen.getByLabelText("Views section options"));
fireEvent.click(screen.getByRole("menuitem", { name: "Edit rows…" }));
expect(screen.getByRole("checkbox", { name: "Hot leads" })).toBeTruthy();
});
});
describe("edit mode ends", () => {
it("on a click outside the section", () => {
mountRail();
startEditing("Mail");
fireEvent.mouseDown(screen.getByText("me@example.com"));
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
});
it("not on a click inside it", () => {
mountRail();
startEditing("Mail");
fireEvent.mouseDown(screen.getByRole("checkbox", { name: "Spam" }));
expect(screen.getAllByRole("checkbox").length).toBeGreaterThan(0);
});
});
describe("arrow keys between rows", () => {
it("move focus down the rail and across sections", () => {
mountRail();
rowOf("All mail").focus();
fireEvent.keyDown(rowOf("All mail"), { key: "ArrowDown" });
expect(document.activeElement).toBe(rowOf("Inbox"));
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
expect(document.activeElement?.textContent).toContain("Interested");
});
});
describe("applyRailOrder", () => {
it("keeps the stored order, drops keys that are gone, and slots new ones after their default neighbour", () => {
expect(applyRailOrder(["a", "b", "c"], ["c", "a", "b"])).toEqual(["c", "a", "b"]);
expect(applyRailOrder(["a", "b"], ["b", "gone", "a"])).toEqual(["b", "a"]);
expect(applyRailOrder(["a", "b", "new", "c"], ["c", "b", "a"])).toEqual(["c", "b", "new", "a"]);
expect(applyRailOrder(["first", "a"], ["a"])).toEqual(["first", "a"]);
expect(applyRailOrder(["a"], undefined)).toEqual(["a"]);
});
});
describe("sanitizeUniboxRailOrder", () => {
it("keeps string lists only, deduplicated", () => {
expect(sanitizeUniboxRailOrder({ mail: ["a", "a", 3, "b"], views: "x", tags: [] })).toEqual({
mail: ["a", "b"],
tags: [],
});
expect(sanitizeUniboxRailOrder(["mail"])).toEqual({});
});
it("is persisted with the section order", () => {
useAppStore.setState({ uniboxRailOrder: { mail: ["unread"] }, uniboxRailSectionOrder: ["views"] });
const persisted = useAppStore.persist.getOptions().partialize?.(useAppStore.getState()) as {
uniboxRailOrder?: unknown;
uniboxRailSectionOrder?: unknown;
};
expect(persisted.uniboxRailOrder).toEqual({ mail: ["unread"] });
expect(persisted.uniboxRailSectionOrder).toEqual(["views"]);
});
});
describe("keyboard moves keep their place", () => {
// Browsers blur a focused node that is moved in the DOM (React restores it); jsdom does not, so mimic it.
const insertBefore = Node.prototype.insertBefore;
beforeEach(() => {
Node.prototype.insertBefore = function <T extends Node>(this: Node, node: T, child: Node | null): T {
const focused = document.activeElement;
if (node.isConnected && focused && node.contains(focused)) (focused as HTMLElement).blur();
return insertBefore.call(this, node, child) as T;
};
});
afterEach(() => {
Node.prototype.insertBefore = insertBefore;
});
it("keeps focus on the row it moved", () => {
mountRail();
rowOf("Inbox").focus();
fireEvent.keyDown(rowOf("Inbox"), { key: "ArrowDown", altKey: true });
expect(document.activeElement).toBe(rowOf("Inbox"));
fireEvent.keyDown(rowOf("Inbox"), { key: "ArrowDown", altKey: true });
expect(useAppStore.getState().uniboxRailOrder.mail.slice(0, 4)).toEqual([
"all", "unread", "awaiting", "folder:inbox",
]);
});
it("keeps focus on the grip it moved", () => {
mountRail();
startEditing("Mail");
const grip = () => screen.getByRole("button", { name: /^Move Inbox/ });
grip().focus();
fireEvent.keyDown(grip(), { key: "ArrowDown" });
expect(document.activeElement).toBe(grip());
});
it("offers no moves in a folded section", () => {
useAppStore.setState({ uniboxRailFolded: { mail: true } });
mountRail({ kind: "unread" });
fireEvent.click(screen.getByLabelText("Unread actions"));
expect((screen.getByRole("menuitem", { name: /Move up/ }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("menuitem", { name: /Move down/ }) as HTMLButtonElement).disabled).toBe(true);
});
});
describe("dropdown keys", () => {
it("jump to the first matching item from the panel, and never reach global shortcuts", () => {
const globalKeys = vi.fn();
window.addEventListener("keydown", globalKeys);
try {
mountRail();
fireEvent.click(screen.getByLabelText("Mail section options"));
fireEvent.keyDown(screen.getByRole("menu"), { key: "f" });
expect(document.activeElement?.textContent).toBe("Fold section");
fireEvent.keyDown(document.activeElement as Element, { key: "e" });
expect(document.activeElement?.textContent).toBe("Edit rows…");
expect(globalKeys).not.toHaveBeenCalled();
} finally {
window.removeEventListener("keydown", globalKeys);
}
});
it("leave Tab alone", () => {
mountRail();
fireEvent.click(screen.getByLabelText("Mail section options"));
fireEvent.keyDown(screen.getByRole("menu"), { key: "Tab" });
expect(screen.getByRole("menu")).toBeTruthy();
});
});
+5 -5
View File
@@ -213,11 +213,11 @@ function NavTip({
}
// The two row shapes share one element and transition between each other in
// step with the sidebar's width: the icon holds its place (it drifts 3px into
// the rail's centre) while the label column fades and is clipped.
const ROW_BASE = "group relative flex items-center rounded-md text-[12.5px] transition-[margin,width,height,padding,gap,background-color,color] duration-200 ease-out motion-reduce:transition-none";
// step with the sidebar's width. Margin and padding match, so the icon sits at
// the rail's centre in both and never moves while the label fades and clips.
const ROW_BASE = "group relative flex items-center rounded-md text-[12.5px] transition-[width,height,gap,background-color,color] duration-200 ease-out motion-reduce:transition-none";
const ICON_ROW = `${ROW_BASE} mx-3 w-8 h-8 px-[9px] gap-0`;
const LABEL_ROW = `${ROW_BASE} mx-2 w-[calc(100%-1rem)] h-7 px-2.5 gap-2.5`;
const LABEL_ROW = `${ROW_BASE} mx-3 w-[calc(100%-1.5rem)] h-7 px-[9px] gap-2.5`;
const rowClass = (collapsed: boolean) => (collapsed ? ICON_ROW : LABEL_ROW);
// Fades out fast on collapse, and back in once the column has room again.
@@ -769,7 +769,7 @@ function Section({
onClick={() => toggleNavSection(section.id)}
inert={collapsed}
className={cn(
"group/section mx-2 flex w-[calc(100%-1rem)] items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-md px-2 text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400 transition-[height,margin,opacity,color] duration-200 ease-out hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 motion-reduce:transition-none",
"group/section mx-3 flex w-[calc(100%-1.5rem)] items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-md px-[9px] text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400 transition-[height,margin,opacity,color] duration-200 ease-out hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 motion-reduce:transition-none",
collapsed ? "mb-0 h-0 opacity-0" : "mb-1 h-6 opacity-100",
)}
>
+4 -4
View File
@@ -47,12 +47,12 @@ export function UserNav({ collapsed = false }: { collapsed?: boolean }) {
<button
aria-label={collapsed ? displayName : undefined}
// One element in both shapes so it eases with the sidebar's
// width: the avatar drifts to the rail's centre, the name fades.
// width: the avatar keeps its place at the rail's centre, the name fades.
className={cn(
"flex items-center mx-3 my-2 rounded-md hover:bg-slate-200/40 cursor-pointer transition-[width,padding,gap,background-color] duration-200 ease-out motion-reduce:transition-none",
"flex items-center mx-3 my-2 pl-0.5 rounded-md hover:bg-slate-200/40 cursor-pointer transition-[width,padding,gap,background-color] duration-200 ease-out motion-reduce:transition-none",
collapsed
? "w-8 gap-0 p-0.5"
: "w-[calc(100%-1.5rem)] gap-2.5 px-1.5 py-1",
? "w-8 gap-0 pr-0.5 py-0.5"
: "w-[calc(100%-1.5rem)] gap-2.5 pr-1.5 py-1",
)}
>
<div className="w-7 h-7 rounded-full bg-slate-900 flex items-center justify-center shrink-0 overflow-hidden">
+15 -7
View File
@@ -1,17 +1,25 @@
// The checkbox square of the app's pickers (category, tag and column
// choosers): a filled slate square with a white check when on, a bordered
// white square when off. Purely visual; the row it sits in is the control.
// choosers): a filled square with a white check when on, a bordered white
// square when off. Purely visual; the row it sits in is the control.
// Tones match Checkbox: slate for option toggles, sky for choosing what shows.
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export function CheckSquare({ checked }: { checked: boolean }) {
const TONE = {
slate: "border-slate-900 bg-slate-900",
sky: "border-sky-600 bg-sky-600",
} as const;
export function CheckSquare({ checked, tone = "slate" }: { checked: boolean; tone?: keyof typeof TONE }) {
return (
<span
className={`size-3.5 rounded border flex items-center justify-center transition-colors shrink-0 ${
checked ? "border-slate-900 bg-slate-900" : "border-slate-300 bg-white"
}`}
className={cn(
"size-3.5 rounded border flex items-center justify-center transition-colors shrink-0",
checked ? TONE[tone] : "border-slate-300 bg-white",
)}
>
{checked && <CheckIcon className="w-2 h-2 text-white" />}
{checked && <CheckIcon className="w-2 h-2 text-white" strokeWidth={3.5} />}
</span>
);
}
+77 -31
View File
@@ -53,6 +53,7 @@ interface MenuCtx {
side: "bottom" | "top";
align: "start" | "end" | "center";
sideOffset: number;
anchorPoint: { x: number; y: number } | null;
}
const Ctx = createContext<MenuCtx | null>(null);
@@ -70,6 +71,7 @@ export function PopoverMenu({
sideOffset = 6,
open: controlledOpen,
onOpenChange,
anchorPoint = null,
}: {
children: React.ReactNode;
side?: "bottom" | "top";
@@ -77,6 +79,8 @@ export function PopoverMenu({
sideOffset?: number;
open?: boolean;
onOpenChange?: (o: boolean) => void;
/** Open at a viewport point instead of under the trigger (a context menu). */
anchorPoint?: { x: number; y: number } | null;
}) {
const id = useId();
const triggerRef = useRef<HTMLElement>(null);
@@ -90,7 +94,7 @@ export function PopoverMenu({
[controlledOpen, onOpenChange],
);
return (
<Ctx.Provider value={{ id, open, setOpen, triggerRef, side, align, sideOffset }}>
<Ctx.Provider value={{ id, open, setOpen, triggerRef, side, align, sideOffset, anchorPoint }}>
{children}
</Ctx.Provider>
);
@@ -148,8 +152,10 @@ export function PopoverMenuContent({
/** Pin the panel to the trigger's measured width (for full-width selects). */
matchTriggerWidth?: boolean;
}) {
const { open, setOpen, triggerRef, side, align, sideOffset } = useMenu();
const { open, setOpen, triggerRef, side, align, sideOffset, anchorPoint } = useMenu();
const ref = useRef<HTMLDivElement>(null);
// Where focus was when the menu opened, so closing gives it back.
const returnFocus = useRef<HTMLElement | null>(null);
const [pos, setPos] = useState<{ top: number; left: number; width?: number } | null>(null);
useLayoutEffect(() => {
@@ -160,8 +166,10 @@ export function PopoverMenuContent({
const compute = () => {
const t = triggerRef.current;
const c = ref.current;
if (!t || !c) return;
const r = t.getBoundingClientRect();
if ((!t && !anchorPoint) || !c) return;
const r = anchorPoint
? new DOMRect(anchorPoint.x, anchorPoint.y, 0, 0)
: t!.getBoundingClientRect();
const cw = c.offsetWidth;
const ch = c.offsetHeight;
let top: number;
@@ -185,8 +193,13 @@ export function PopoverMenuContent({
setPos({ top, left, width: r.width });
};
compute();
// A context menu has no element to follow, so a scroll elsewhere closes it.
const onScroll = (e: Event) => {
if (anchorPoint && !ref.current?.contains(e.target as Node)) setOpen(false);
else compute();
};
window.addEventListener("resize", compute);
window.addEventListener("scroll", compute, true);
window.addEventListener("scroll", onScroll, true);
// Recompute when the popover's own content changes size — e.g.
// swapping a preset list for a datetime picker inside. Without
@@ -200,35 +213,66 @@ export function PopoverMenuContent({
return () => {
window.removeEventListener("resize", compute);
window.removeEventListener("scroll", compute, true);
window.removeEventListener("scroll", onScroll, true);
observer?.disconnect();
};
}, [open, side, align, sideOffset, triggerRef]);
}, [open, side, align, sideOffset, triggerRef, anchorPoint, setOpen]);
// Keyboard: focus lands on the panel (never over an autofocused input), and
// closing hands it back to whatever held it before, if it is still there.
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
const t = e.target as Node;
if (ref.current?.contains(t)) return;
if (triggerRef.current?.contains(t)) return;
// A click inside another portaled floating layer this menu opened (a
// date-picker calendar, a nested SelectMenu) must not close this menu.
const el = t as Element | null;
if (el && typeof el.closest === "function" && el.closest("[data-floating]")) return;
setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
// Capture phase: dialogs stop mousedown propagation on their card so the
// backdrop does not close them, which would otherwise swallow this too.
document.addEventListener("mousedown", onClick, true);
document.addEventListener("keydown", onKey);
const panel = ref.current;
const active = document.activeElement as HTMLElement | null;
if (!panel?.contains(active)) returnFocus.current = active;
const frame = requestAnimationFrame(() => {
if (panel && !panel.contains(document.activeElement)) panel.focus({ preventScroll: true });
});
return () => {
document.removeEventListener("mousedown", onClick, true);
document.removeEventListener("keydown", onKey);
cancelAnimationFrame(frame);
const back = returnFocus.current;
returnFocus.current = null;
const now = document.activeElement;
const inside = !now || now === document.body || !!panel?.contains(now);
if (back?.isConnected && inside) back.focus({ preventScroll: true });
};
}, [open, setOpen, triggerRef]);
}, [open]);
// Arrow keys, Home/End and typeahead over this panel's own items. Keys it
// handles stop here, so a nested menu's keys never reach this one and a
// letter never also runs a global shortcut.
const onMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (!ref.current?.contains(target)) return;
if (target.matches("input, textarea, select, [contenteditable='true']")) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const items = Array.from(
ref.current.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'),
);
const typeahead = e.key.length === 1 && /\S/.test(e.key);
if (!items.length || (!typeahead && !["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key))) return;
e.stopPropagation();
const at = items.indexOf(target);
let next = -1;
if (e.key === "ArrowDown") next = at < 0 ? 0 : (at + 1) % items.length;
else if (e.key === "ArrowUp") next = at < 0 ? items.length - 1 : (at - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
else {
// The next item after the focused one whose label starts with the key.
const k = e.key.toLowerCase();
for (let i = 1; i <= items.length; i++) {
const idx = (at + i + items.length) % items.length;
if (items[idx].textContent?.trim().toLowerCase().startsWith(k)) {
next = idx;
break;
}
}
}
if (next < 0) return;
e.preventDefault();
items[next].focus();
};
// Anchor the animation origin to the side the menu opens from so
// the scale + lift feels like it's growing out of the trigger
@@ -259,7 +303,9 @@ export function PopoverMenuContent({
ref={ref}
key="popover"
role="menu"
tabIndex={-1}
data-floating="true"
onKeyDown={onMenuKeyDown}
layout
initial={{ opacity: 0, scale: 0.96, y: enterY }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -292,7 +338,7 @@ export function PopoverMenuContent({
willChange: "transform, opacity",
}}
className={cn(
"rounded-md border border-slate-200 bg-white shadow-[0_4px_12px_-2px_rgba(15,23,42,0.08),0_2px_4px_rgba(15,23,42,0.04)] overflow-hidden py-1",
"outline-none rounded-md border border-slate-200 bg-white shadow-[0_4px_12px_-2px_rgba(15,23,42,0.08),0_2px_4px_rgba(15,23,42,0.04)] overflow-hidden py-1",
// Default viewport cap so tall menus scroll instead of
// clipping off a phone screen; callers with a tighter
// max-h still win via the cn merge below.
@@ -351,10 +397,10 @@ export function PopoverMenuItem({
if (closeOnSelect) setOpen(false);
}}
className={cn(
"w-full h-7 px-3 flex items-center gap-2 text-[12.5px] text-left transition-colors",
"w-full h-7 px-3 flex items-center gap-2 text-[12.5px] text-left transition-colors outline-none",
danger
? "text-red-600 hover:bg-red-50"
: "text-slate-700 hover:bg-slate-50 hover:text-slate-900",
? "text-red-600 hover:bg-red-50 focus-visible:bg-red-50"
: "text-slate-700 hover:bg-slate-50 hover:text-slate-900 focus-visible:bg-slate-50 focus-visible:text-slate-900",
selected && !danger && "text-slate-900 font-medium",
disabled && "opacity-50 cursor-not-allowed",
)}
@@ -0,0 +1,44 @@
import type { LeadCC } from "@/lib/api/models/app/contacts/Contact";
import Request from "../../Request";
export interface LeadCCResult {
campaign_id: string;
contact_id: string;
cc: LeadCC[];
}
// A likely colleague of the lead: same company name, or the same business
// email domain.
export interface LeadCCSuggestion {
contact_id: string;
email: string;
first_name: string;
last_name: string;
company?: string;
reason: "company" | "domain";
}
// Replaces the whole list; an empty list removes every copy.
export async function setCampaignLeadCC(
campaignId: string,
contactId: string,
contactIds: string[],
): Promise<LeadCCResult> {
return await Request<LeadCCResult>({
method: "PUT",
url: `/campaigns/${campaignId}/leads/${contactId}/cc`,
data: { contact_ids: contactIds },
authorization: true,
});
}
export async function getCampaignLeadCCSuggestions(
campaignId: string,
contactId: string,
): Promise<{ data: LeadCCSuggestion[] }> {
return await Request<{ data: LeadCCSuggestion[] }>({
method: "GET",
url: `/campaigns/${campaignId}/leads/${contactId}/cc/suggestions`,
authorization: true,
});
}
@@ -0,0 +1,28 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCampaignLeadCCSuggestions,
setCampaignLeadCC,
type LeadCCResult,
} from "@/lib/api/client/app/campaigns/leadCC";
// Changing who a lead copies moves the Leads list (its CC badge, and a copied
// contact's own lead being held or released) and the contact drawers. The
// server audits it, so teammates get it over the audit spine; this is the
// local echo.
export function useSetLeadCC() {
const queryClient = useQueryClient();
return useMutation<LeadCCResult, unknown, { campaignId: string; contactId: string; contactIds: string[] }>({
mutationFn: ({ campaignId, contactId, contactIds }) => setCampaignLeadCC(campaignId, contactId, contactIds),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["contacts"] }),
});
}
// Keyed under ["contacts", id] so the same invalidations refresh it.
export function useLeadCCSuggestions(campaignId: string, contactId: string, enabled: boolean) {
return useQuery({
queryKey: ["contacts", contactId, "cc-suggestions", campaignId],
queryFn: () => getCampaignLeadCCSuggestions(campaignId, contactId),
enabled: enabled && !!campaignId && !!contactId,
staleTime: 30_000,
});
}
+32 -3
View File
@@ -21,19 +21,46 @@ export type LeadStatus =
| "undeliverable";
// One contact's flow parked inside one campaign. source is "out_of_office"
// when an auto-reply parked it and "manual" when a member did; `until` absent
// means the hold has no end and only a resume lifts it.
// when an auto-reply parked it, "manual" when a member did, and "cc" while the
// contact is copied on another lead's emails (reason is that lead's address);
// `until` absent means the hold has no end and only a resume lifts it.
export interface LeadHold {
since: Date;
until?: Date | null;
reason?: string;
source: "manual" | "out_of_office" | string;
source: "manual" | "out_of_office" | "inbox_tagging" | "cc" | string;
}
// Why a copied contact is or is not on the next email to the lead.
export type LeadCCStatus = "active" | "unsubscribed" | "bounced" | "undeliverable";
// A contact copied on every email one campaign sends one lead.
export interface LeadCC {
contact_id: string;
email: string;
first_name: string;
last_name: string;
company?: string;
status: LeadCCStatus;
bounced_at?: Date | null;
}
// The most contacts one lead can copy; mirrors config.CampaignLeadMaxCC.
export const LEAD_CC_MAX = 2;
export function leadCCName(cc: Pick<LeadCC, "first_name" | "last_name" | "email">): string {
return `${cc.first_name ?? ""} ${cc.last_name ?? ""}`.trim() || cc.email;
}
// holdSummary is the one sentence a held lead gets, wherever it is shown: why
// the flow is parked and when it lifts. One function so the Leads row and the
// contact drawer cannot word the same hold two different ways.
export function holdSummary(hold: LeadHold): string {
if (hold.source === "cc") {
return hold.reason
? `Copied on the emails to ${hold.reason} · none of their own are sent`
: "Copied on another lead's emails · none of their own are sent";
}
const what = hold.source === "out_of_office" ? "Out of office" : "Paused";
const why = hold.reason ? ` · ${hold.reason}` : "";
if (!hold.until) return `${what}${why} · until someone resumes it`;
@@ -83,6 +110,8 @@ export interface ContactCampaignProgress {
// The live hold, when the lead's flow is parked. Present on any status: a
// held lead that also replied still reads "replied".
hold?: LeadHold | null;
// Contacts copied on every email to this lead in this campaign.
cc?: LeadCC[];
}
// VerificationStatus mirrors emailverify.Status: the pre-send verdict on the
@@ -1,4 +1,4 @@
import type { LeadHold, LeadStatus } from "./Contact";
import type { LeadCC, LeadHold, LeadStatus } from "./Contact";
// One campaign a contact belongs to, as the Activity tab's campaign panel
// shows it: the flow with this contact's progress, the derived lead status,
@@ -68,6 +68,9 @@ export default interface ContactCampaignState {
// or a member paused them. Absent when the lead is not held.
hold?: LeadHold | null;
// Contacts copied on every email to this lead in this campaign.
cc?: LeadCC[];
next?: ContactNextAction | null;
ended_reason?: string;
}
+17
View File
@@ -24,6 +24,23 @@ describe("reviveDates", () => {
expect(r).toEqual({ date: "2026-09-27", custom_fields: { renewal: "2026-09-27T00:00:00Z" } });
});
it("keeps the cells of an uploaded file as text", () => {
const cell = "2026-09-23T08:16:51+00:00";
const body = {
created_at: cell,
preview: {
columns: [cell],
sample_rows: [["John", cell]],
column_stats: [{ filled: 1, distinct: 1, samples: [cell] }],
},
failures: [{ line: 2, email: cell, values: ["John", cell], reason: "invalid email" }],
columns: [{ index: 0, header: cell, samples: [cell] }],
};
const r = reviveDates(body) as unknown as { created_at: Date };
expect(r.created_at).toBeInstanceOf(Date);
expect({ ...r, created_at: cell }).toEqual(body);
});
it("revives inside arrays and nested objects", () => {
const r = reviveDates({ data: [{ created_at: "2026-09-27T00:00:00Z" }] }) as unknown as { data: { created_at: Date }[] };
expect(r.data[0].created_at).toBeInstanceOf(Date);
+2 -2
View File
@@ -2,8 +2,8 @@
// ends so free text that merely starts with a date (a subject, a snippet) stays text.
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/
// User-authored string maps: a value that looks like a timestamp is still the text someone typed.
const VERBATIM_KEYS = new Set(["custom_fields"])
// User-authored strings and cells read from an uploaded file: a value that looks like a timestamp is still text.
const VERBATIM_KEYS = new Set(["custom_fields", "sample_rows", "samples", "values", "columns", "header", "email"])
export default function reviveDates<T>(obj: T): T {
if (obj === null || obj === undefined) return obj
+5
View File
@@ -44,3 +44,8 @@ export function tickedCampaigns<T extends { campaign_id: string }>(campaigns: T[
const ticked = campaigns.filter((c) => !skipped.includes(c.campaign_id));
return ticked.length > 0 ? ticked : campaigns;
}
// Resuming a lead held because it is copied on another lead's thread starts a
// second sequence to the same person, which the hold exists to prevent.
export const CC_RESUME_CONFIRM =
"Start this contact's own sequence as well? They will get two threads from this campaign: their own, and the one they are copied on.";
+79
View File
@@ -33,6 +33,14 @@ export interface UISlice {
// Folded sidebar sections, keyed by the section's stable id (not its label).
navCollapsedSections: Record<string, boolean>
// Unibox scope rail: folded sections (stable ids, same shape as the nav map)
// and the rows the user hid, as the rail's scopeKey values.
uniboxRailFolded: Record<string, boolean>
uniboxRailHidden: string[]
// Row order per section and the order of the sections; absent means default.
uniboxRailOrder: Record<string, string[]>
uniboxRailSectionOrder: string[]
// Theme
theme: Theme
resolvedTheme: 'light' | 'dark'
@@ -59,6 +67,14 @@ export interface UISlice {
setSidebarMobileOpen: (open: boolean) => void
toggleNavSection: (id: string) => void
// Actions - Unibox scope rail
toggleUniboxRailSection: (id: string) => void
toggleUniboxRailRow: (key: string) => void
setUniboxRailFolded: (folded: Record<string, boolean>) => void
setUniboxRailRowsHidden: (keys: string[], hidden: boolean) => void
setUniboxRailOrder: (section: string, keys: string[] | null) => void
setUniboxRailSectionOrder: (ids: string[]) => void
// Actions - Theme
setTheme: (theme: Theme) => void
setResolvedTheme: (theme: 'light' | 'dark') => void
@@ -89,6 +105,38 @@ export const sanitizeNavCollapsedSections = (v: unknown): Record<string, boolean
return Object.fromEntries(Object.entries(v).filter(([, folded]) => typeof folded === 'boolean'))
}
// Same rehydration gap for the rail's hidden rows: anything that is not a
// string would never match a scope key, and a duplicate would make one click
// on the row's checkbox appear to do nothing.
export const sanitizeUniboxRailHidden = (v: unknown): string[] => {
if (!Array.isArray(v)) return []
return [...new Set(v.filter((k): k is string => typeof k === 'string'))]
}
// Row orders rehydrate as a map of string lists, each deduplicated.
export const sanitizeUniboxRailOrder = (v: unknown): Record<string, string[]> => {
if (!v || typeof v !== 'object' || Array.isArray(v)) return {}
return Object.fromEntries(
Object.entries(v).flatMap(([id, keys]) => (Array.isArray(keys) ? [[id, sanitizeUniboxRailHidden(keys)]] : [])),
)
}
// A stored order over keys that come and go: known keys keep the stored order,
// and a key the stored order never saw lands right after its default predecessor.
export const applyRailOrder = (defaults: string[], stored: string[] | undefined): string[] => {
if (!stored?.length) return defaults
const known = new Set(defaults)
const out = stored.filter((k) => known.has(k))
const placed = new Set(out)
defaults.forEach((k, i) => {
if (placed.has(k)) return
const prev = i > 0 ? out.indexOf(defaults[i - 1]) : -1
out.splice(prev + 1, 0, k)
placed.add(k)
})
return out
}
// The dashboard is light-only today: every surface is styled on white, so a
// resolved dark theme would flip only the CSS-variable components (command
// palette, toasts) and look broken. 'dark'/'system' are accepted but resolve
@@ -102,6 +150,10 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
navCollapsed: false,
sidebarMobileOpen: false,
navCollapsedSections: {},
uniboxRailFolded: {},
uniboxRailHidden: [],
uniboxRailOrder: {},
uniboxRailSectionOrder: [],
// Theme
theme: getInitialTheme(),
@@ -131,6 +183,33 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
navCollapsedSections: { ...state.navCollapsedSections, [id]: !state.navCollapsedSections[id] },
})),
// Actions - Unibox scope rail
toggleUniboxRailSection: (id) =>
set((state) => ({
uniboxRailFolded: { ...state.uniboxRailFolded, [id]: !state.uniboxRailFolded[id] },
})),
toggleUniboxRailRow: (key) =>
set((state) => ({
uniboxRailHidden: state.uniboxRailHidden.includes(key)
? state.uniboxRailHidden.filter((k) => k !== key)
: [...state.uniboxRailHidden, key],
})),
setUniboxRailFolded: (folded) =>
set((state) => ({ uniboxRailFolded: { ...state.uniboxRailFolded, ...folded } })),
setUniboxRailRowsHidden: (keys, hidden) =>
set((state) => {
const rest = state.uniboxRailHidden.filter((k) => !keys.includes(k))
return { uniboxRailHidden: hidden ? [...rest, ...keys] : rest }
}),
setUniboxRailOrder: (section, keys) =>
set((state) => {
const next = { ...state.uniboxRailOrder }
if (keys) next[section] = keys
else delete next[section]
return { uniboxRailOrder: next }
}),
setUniboxRailSectionOrder: (ids) => set({ uniboxRailSectionOrder: ids }),
// Actions - Theme
setTheme: (theme) => {
if (get().theme === theme) return
+10 -1
View File
@@ -3,7 +3,7 @@ import { devtools, persist } from 'zustand/middleware'
import { useShallow } from 'zustand/react/shallow'
import { createUserSlice, type UserSlice } from './slices/userSlice'
import { createOrganizationSlice, type OrganizationSlice } from './slices/organizationSlice'
import { createUISlice, clampUniboxListWidth, sanitizeNavCollapsedSections, type UISlice } from './slices/uiSlice'
import { createUISlice, clampUniboxListWidth, sanitizeNavCollapsedSections, sanitizeUniboxRailHidden, sanitizeUniboxRailOrder, type UISlice } from './slices/uiSlice'
import { createShortcutSlice, type ShortcutSlice } from './slices/shortcutSlice'
import { createDataSlice, type DataSlice } from './slices/dataSlice'
import { createRealtimeSlice, type RealtimeSlice } from './slices/realtimeSlice'
@@ -48,6 +48,10 @@ export const useAppStore = create<AppStore>()(
...p,
uniboxListWidth: clampUniboxListWidth(p.uniboxListWidth),
navCollapsedSections: sanitizeNavCollapsedSections(p.navCollapsedSections),
uniboxRailFolded: sanitizeNavCollapsedSections(p.uniboxRailFolded),
uniboxRailHidden: sanitizeUniboxRailHidden(p.uniboxRailHidden),
uniboxRailOrder: sanitizeUniboxRailOrder(p.uniboxRailOrder),
uniboxRailSectionOrder: sanitizeUniboxRailHidden(p.uniboxRailSectionOrder),
}
},
partialize: (state) => ({
@@ -63,6 +67,11 @@ export const useAppStore = create<AppStore>()(
// Unibox layout (list column width + CRM rail default)
uniboxListWidth: state.uniboxListWidth,
uniboxContactRailOpen: state.uniboxContactRailOpen,
// Unibox scope rail (folds, hidden rows, row and section order)
uniboxRailFolded: state.uniboxRailFolded,
uniboxRailHidden: state.uniboxRailHidden,
uniboxRailOrder: state.uniboxRailOrder,
uniboxRailSectionOrder: state.uniboxRailSectionOrder,
// Persist current organization selection
currentOrganization: state.currentOrganization,
}),