mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 16:02:02 +00:00
Merge pull request #754 from warmbly/fix/contact-bulk-request-limit
feat: raise contact bulk selection limits and name categories and labels Labels everywhere
This commit is contained in:
@@ -414,7 +414,7 @@ Mutating API requests may include an `Idempotency-Key` header. Warmbly stores th
|
||||
|
||||
### Labels
|
||||
|
||||
Folders (on campaigns), tags (on mailboxes) and categories (on contacts and inbox conversations) are three registries with the same shape. Each belongs to the **workspace**, not to whoever created it. Every member sees the same set, whoever created it, and reads it from `GET /auth/me`, which returns `folders`, `tags` and `categories` for the session's selected workspace; there is no separate list endpoint and no read scope of its own. Changing a registry is gated like the records it labels, so it takes the API permission in the table below, or the matching JWT permission `MANAGE_CAMPAIGNS`, `MANAGE_EMAILS` or `MANAGE_CONTACTS`. Membership alone is read-only.
|
||||
Folders (on campaigns), tags (on mailboxes) and labels (on contacts, inbox conversations and forms, managed through the `/categories` endpoints) are three registries with the same shape. Each belongs to the **workspace**, not to whoever created it. Every member sees the same set, whoever created it, and reads it from `GET /auth/me`, which returns `folders`, `tags` and `categories` for the session's selected workspace; there is no separate list endpoint and no read scope of its own. Changing a registry is gated like the records it labels, so it takes the API permission in the table below, or the matching JWT permission `MANAGE_CAMPAIGNS`, `MANAGE_EMAILS` or `MANAGE_CONTACTS`. Membership alone is read-only.
|
||||
|
||||
`position` is the order within its own registry, `0`-based and contiguous. A move returns the full new ordering. A registry holds at most `100` entries.
|
||||
|
||||
|
||||
@@ -105,7 +105,8 @@ Fields are named by their JSON key, with nested fields as a dotted path (`inner.
|
||||
| `no_leads` | `POST /campaigns/:id/start` on a campaign that has never had a lead, with `continuous` off. Add contacts, or set `continuous` so it starts empty and waits for them. A campaign whose leads have all finished is a different case: it starts and waits |
|
||||
| `no_remaining_leads` | A platform-initiated restart of a campaign with nothing left to send and `continuous` off found nothing to do; the campaign is `completed` again. A start you request never answers this: it turns `continuous` on and waits |
|
||||
| `too_many_tasks` | `PATCH /crm/tasks` or `DELETE /crm/tasks` was given more than `1000` ids in one request, or more than `50,000` exclusions. Split it into batches |
|
||||
| `selection_too_large` | A `"all": true` bulk selection resolved to more than `50,000` rows. Narrow the filter and run it in parts; nothing was changed |
|
||||
| `too_many_contacts` | A contact bulk action was given more than `10,000` contact ids in one request, or more than `250,000` exclusions. Split it into batches, or send a filter selection instead of ids |
|
||||
| `selection_too_large` | A `"all": true` bulk selection resolved to more than its limit: `250,000` contacts, or `50,000` CRM tasks. Narrow the filter and run it in parts; nothing was changed |
|
||||
| `invalid_filter` | A task filter carried an id that is not one: `assigned_to`, `contact_id` and `deal_id` name records, and are matched against id columns. Sent by `POST /crm/tasks/search`, `POST /crm/tasks/summary`, and the `filters` of a `"all": true` bulk selection |
|
||||
| `invalid_setting` | `PATCH /outreach/settings` (or a campaign's advanced settings) carried a value outside the documented vocabulary, for example a `reply_intent.crm_task_intents` entry that is not a reply intent, or an `inbox_tagging.questions` entry with no question text, a label that is missing, repeated or built in, or a choice question with fewer than two options, or an `inbox_tagging.languages` entry that is not a supported language code |
|
||||
| `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 |
|
||||
|
||||
@@ -102,7 +102,7 @@ Every tool is gated by its API permission. `tools/list` returns only the tools y
|
||||
| `search_contacts` | Find contacts by text | `READ_CONTACTS` |
|
||||
| `get_contact` | Read one contact | `READ_CONTACTS` |
|
||||
| `update_contact_fields` | Update contact fields | `WRITE_CONTACTS` |
|
||||
| `add_tag` / `remove_tag` | Tag a contact | `WRITE_CONTACTS` |
|
||||
| `add_tag` / `remove_tag` | Add or remove a label on a contact | `WRITE_CONTACTS` |
|
||||
| `list_campaigns` | List campaigns | `READ_CAMPAIGNS` |
|
||||
| `get_campaign_stats` | Campaign stats, all time or for the emails sent from `from` to `to` | `READ_ANALYTICS` |
|
||||
| `list_campaign_leads` | A campaign's leads with derived status + totals | `READ_CONTACTS` |
|
||||
@@ -116,7 +116,7 @@ Every tool is gated by its API permission. `tools/list` returns only the tools y
|
||||
| `list_placement_batches` / `get_placement_batch` | Placement batches, their progress, and placement by sending domain, sending provider and recipient provider | `READ_ANALYTICS` |
|
||||
| `run_placement_batch` | Run the same placement test from a campaign's senders or the whole workspace, optionally sampled. Signed-in members only: an API key starts a batch with `POST /placement/batches`, which applies the key's mailbox limits | `SEND_CAMPAIGNS` |
|
||||
| `add_contact` / `delete_contact` | Create or delete a contact | `WRITE_CONTACTS` |
|
||||
| `bulk_edit_contacts` | Tag or subscribe many contacts at once | `BULK_CONTACTS` |
|
||||
| `bulk_edit_contacts` | Label or subscribe many contacts at once | `BULK_CONTACTS` |
|
||||
| `get_contact_timeline` / `get_contact_sent_emails` | Read a contact's activity and sent mail | `READ_CONTACTS` |
|
||||
| `list_segments` / `get_segment` / `list_segment_fields` | Read saved audiences, and the fields a condition can name | `READ_CONTACTS` |
|
||||
| `preview_segment` | Count what a set of conditions would match, saving nothing | `READ_CONTACTS` |
|
||||
|
||||
@@ -375,9 +375,9 @@ Auth: Session only (not available to API keys).
|
||||
|
||||
`GET /auth/me`
|
||||
|
||||
Returns the signed-in user, including admin flags and the label registries (folders, tags, categories) the dashboard needs on initial load.
|
||||
Returns the signed-in user, including admin flags and the label registries (folders, tags, and the workspace labels returned as `categories`) the dashboard needs on initial load.
|
||||
|
||||
The registries belong to the workspace, not to the caller: every member of an organization sees the same folders, tags and categories, whoever created them. They are read for the session's currently selected workspace, so switching workspaces changes what comes back.
|
||||
The registries belong to the workspace, not to the caller: every member of an organization sees the same folders, tags and labels, whoever created them. They are read for the session's currently selected workspace, so switching workspaces changes what comes back.
|
||||
|
||||
Auth: Session only (not available to API keys).
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`
|
||||
| --- | --- | --- | --- |
|
||||
| `cursor` | query | string | Opaque pagination cursor from the previous page's `pagination.next_cursor`. It carries the exact position of the next page under the ordering it was issued for, so rows that share a sort value are never skipped or repeated. A malformed cursor, or one replayed with a different `sort_by` or `reverse` than it was issued under, is a `400`. |
|
||||
| `limit` | query | string | Page size (numeric string). |
|
||||
| `category` | query | string | Convenience filter for a single category ID. |
|
||||
| `category` | query | string | Convenience filter for a single label ID. |
|
||||
|
||||
### Request body
|
||||
|
||||
@@ -30,7 +30,7 @@ Every field is optional; an empty body matches all contacts in the organization.
|
||||
| `campaign_ids` | string[] | No | Contact must be in ALL of these campaigns. |
|
||||
| `lead_status` | string | No | Filter to one derived lead status: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `paused`, `undeliverable`, or `unsubscribed`. Requires exactly one `campaign_ids` entry, otherwise the request is rejected with `lead_filter_requires_campaign`; an unknown value is rejected with `invalid_lead_status`. |
|
||||
| `engagement` | string | No | Filter by engagement inside that campaign: `opened`, `not_opened`, `clicked`, `not_clicked`, `replied`, `not_replied`, or `bounced`. `opened` means a human open (machine opens never count); the `not_*` values match only leads sent at least one step. Combines with `lead_status` as AND. Requires exactly one `campaign_ids` entry (`lead_filter_requires_campaign`); an unknown value is rejected with `invalid_engagement`. |
|
||||
| `category_ids` | string[] | No | Contact must have ALL of these categories. |
|
||||
| `category_ids` | string[] | No | Label IDs. The contact must have ALL of these labels. |
|
||||
| `segment_ids` | string[] | No | Contact must be a member of ALL of these segments (conditions plus manual overrides). An id that is not a valid UUID is rejected with `400`; an unknown segment matches nothing. |
|
||||
| `verification_status` | string | No | Filter by verification verdict: `valid`, `risky`, `invalid`, or `unknown`. |
|
||||
| `mail_hosts` | string[] | No | Contacts whose inbox is hosted by any of these `mail_host` values (see [Email provider](#email-provider)). `""` matches contacts with no known provider: not checked yet, or a domain with no mail server. An unknown value is rejected with `invalid_mail_host`. |
|
||||
@@ -160,7 +160,7 @@ A JSON array of contact objects (at least one, up to the per-request maximum; an
|
||||
| `company` | string | No | Company name. |
|
||||
| `phone` | string | No | Phone number. |
|
||||
| `campaigns` | string[] | No | Campaign IDs to add the contact to. |
|
||||
| `categories` | string[] | No | Category IDs to assign. |
|
||||
| `categories` | string[] | No | Label IDs to put on the contact. |
|
||||
| `segments` | string[] | No | Segment IDs to pin the contact into, as a manual include override, so it belongs whether or not the conditions match it. An unknown id is rejected with `400` before any contact is written, and the override is written in the same transaction as the contact, so a success response always means the membership exists. |
|
||||
| `custom_fields` | object | No | String key/value custom fields. Keys may use letters, numbers, underscores, spaces, and dashes. |
|
||||
| `subscribed` | boolean | No | Marketing-consent flag. Omit it to let a new contact default to subscribed and an existing one keep whatever it already had. |
|
||||
@@ -212,16 +212,16 @@ Returns the created contacts as a bare JSON array (same contact shape as search)
|
||||
|
||||
Every endpoint that acts on a set of contacts (`PATCH /contacts`, `DELETE /contacts`, `POST /contacts/verification`, `POST /contacts/research/batch`, `POST /segments/:id/members` and `POST /integrations/connections/:id/push`) names that set one of two ways.
|
||||
|
||||
**By id.** A `contacts` array of up to 1000 ids, the original shape. Nothing about it has changed.
|
||||
**By id.** A `contacts` array of up to 10,000 ids, the original shape.
|
||||
|
||||
**By filter.** Set `all` to `true` and pass the same body `POST /contacts/search` takes as `filters`. The server resolves that search and applies the action to every contact it matches, so one call can cover far more than a page. `exclude` drops ids back out of the resolved set, which is how the dashboard handles rows unticked after a select-all.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `contacts` | string[] | Yes, unless `all` | Contact ids, up to 1000. |
|
||||
| `contacts` | string[] | Yes, unless `all` | Contact ids, up to 10,000 (`too_many_contacts` past that). |
|
||||
| `all` | boolean | No | Resolve the selection from `filters` instead of `contacts`. |
|
||||
| `filters` | object | Yes when `all` | A [contact search](#search-contacts) body. The action applies to everything it matches. |
|
||||
| `exclude` | string[] | No | Contact ids to drop from the resolved set, up to 50,000 (`too_many_contacts` past that). Ignored unless `all`. |
|
||||
| `exclude` | string[] | No | Contact ids to drop from the resolved set, up to 250,000 (`too_many_contacts` past that). Ignored unless `all`. |
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -231,13 +231,13 @@ Every endpoint that acts on a set of contacts (`PATCH /contacts`, `DELETE /conta
|
||||
}
|
||||
```
|
||||
|
||||
A filter selection that matches more than 50,000 contacts is refused with `selection_too_large` rather than truncated; narrow it and repeat. One that matches nothing is a `400`. `POST /integrations/connections/:id/push` accepts the same shape and answers the same way, but still caps the resolved set at 500, because it calls the CRM once per contact inside the request.
|
||||
A filter selection that matches more than 250,000 contacts is refused with `selection_too_large` rather than truncated; narrow it and repeat. One that matches nothing is a `400`. `POST /integrations/connections/:id/push` and `POST /contacts/research/batch` accept the same shape and answer the same way, but still cap the resolved set at 500: a push calls the CRM once per contact inside the request, and each research run spends AI credits.
|
||||
|
||||
## Bulk update contacts
|
||||
|
||||
`PATCH /contacts`
|
||||
|
||||
Applies one set of edits across a [selection of contacts](#selecting-contacts-for-a-bulk-action): add/remove campaigns and categories, set custom-field operations, and toggle subscription.
|
||||
Applies one set of edits across a [selection of contacts](#selecting-contacts-for-a-bulk-action): add/remove campaigns and labels (`add_categories`, `remove_categories`), set custom-field operations, and toggle subscription.
|
||||
|
||||
Auth: **Scope** `BULK_CONTACTS` · **Org permission** `manage_contacts`
|
||||
|
||||
@@ -245,12 +245,12 @@ Auth: **Scope** `BULK_CONTACTS` · **Org permission** `manage_contacts`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `contacts` | string[] | Yes, unless `all` | Contact IDs to edit (1 to 1000). |
|
||||
| `contacts` | string[] | Yes, unless `all` | Contact IDs to edit (1 to 10,000). |
|
||||
| `all`, `filters`, `exclude` | — | No | Select by filter instead; see [selecting contacts](#selecting-contacts-for-a-bulk-action). |
|
||||
| `add_campaigns` | string[] | No | Campaign IDs to add. |
|
||||
| `remove_campaigns` | string[] | No | Campaign IDs to remove. |
|
||||
| `add_categories` | string[] | No | Category IDs to add. |
|
||||
| `remove_categories` | string[] | No | Category IDs to remove. |
|
||||
| `add_categories` | string[] | No | Label IDs to add. |
|
||||
| `remove_categories` | string[] | No | Label IDs to remove. |
|
||||
| `fields` | array | No | Custom-field operations: `{ "type", "key", "value" }` where `type` is `ADD`, `EDIT`, `DELETE`, or `RENAME`. |
|
||||
| `subscribe` | boolean | No | Set subscription status for all listed contacts. |
|
||||
|
||||
@@ -409,10 +409,10 @@ Send `multipart/form-data` with a `file` field and an `options` field containing
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `mapping` | array | Yes | Column mappings: `{ "index", "target", "custom_key", "verification_provider" }`. `target` is `ignore`, `email`, `first_name`, `last_name`, `company`, `phone`, `subscribed`, `categories`, `verification_status`, or `custom` with the name in `custom_key`. `custom:<key>` is still accepted as the older spelling. Exactly one column must map to `email`. A `verification_status` column is read in the vocabulary named by `verification_provider` (see [Create contacts](#create-contacts)), or recognised value by value when it is omitted; a cell nobody recognises leaves that contact unverified rather than failing the row. The preview suggests this target itself when a column's header or values look like another service's results. |
|
||||
| `mapping` | array | Yes | Column mappings: `{ "index", "target", "custom_key", "verification_provider" }`. `target` is `ignore`, `email`, `first_name`, `last_name`, `company`, `phone`, `subscribed`, `categories` (label names), `verification_status`, or `custom` with the name in `custom_key`. `custom:<key>` is still accepted as the older spelling. Exactly one column must map to `email`. A `verification_status` column is read in the vocabulary named by `verification_provider` (see [Create contacts](#create-contacts)), or recognised value by value when it is omitted; a cell nobody recognises leaves that contact unverified rather than failing the row. The preview suggests this target itself when a column's header or values look like another service's results. |
|
||||
| `dedup` | string | Yes | `skip`, `update`, or `create_duplicate` for rows whose email matches an existing contact. |
|
||||
| `has_header` | boolean | Yes | Whether the first row is a header. |
|
||||
| `category_ids` | string[] | No | Categories to assign to imported contacts. |
|
||||
| `category_ids` | string[] | No | Label IDs to put on imported contacts. |
|
||||
| `campaign_ids` | string[] | No | Campaigns to add imported contacts to. |
|
||||
| `segment_ids` | string[] | No | Segments to pin imported contacts into, as a manual include override. Applies to imported, updated, and skipped-but-linked contacts alike. An id that is not a valid UUID, or that names no segment in the organization, is rejected with `400` before any row is written. |
|
||||
| `subscribed_default` | boolean | No | Subscription state for new contacts when no subscribed column is mapped. Defaults to true. |
|
||||
@@ -523,7 +523,7 @@ Reads a draft under a mapping and writes nothing. `409` once the import has star
|
||||
}
|
||||
```
|
||||
|
||||
Every row lands in exactly one of `new`, `existing`, `duplicates_in_file`, `invalid`, or `conflicts` (an address the caller holds as a contact in another organization). `invalid_samples` lists up to 25 of the invalid and conflicting rows. `problem`, when present, is why starting would be refused as a whole: the plan's contact limit, or too many distinct categories.
|
||||
Every row lands in exactly one of `new`, `existing`, `duplicates_in_file`, `invalid`, or `conflicts` (an address the caller holds as a contact in another organization). `invalid_samples` lists up to 25 of the invalid and conflicting rows. `problem`, when present, is why starting would be refused as a whole: the plan's contact limit, or too many distinct labels.
|
||||
|
||||
### Start an import
|
||||
|
||||
@@ -739,7 +739,7 @@ When the contact is suppressed, `suppression` is an object: `{ "id", "kind", "va
|
||||
|
||||
`PATCH /contacts/:id`
|
||||
|
||||
Partially updates a single contact. Only the fields present are changed. Campaign and category lists can be set wholesale or adjusted with diff-style add/remove.
|
||||
Partially updates a single contact. Only the fields present are changed. Campaign and label lists (`categories`) can be set wholesale or adjusted with diff-style add/remove.
|
||||
|
||||
`email` replaces the contact's address. It is stored lowercased, a display name (`Dana Reyes <dana@acme.com>`) is reduced to the address inside it, and anything that is not an address answers `400`. The address has to be free: one another contact already holds answers `409` with `code` `contact_email_taken` rather than merging the two. A changed address drops the contact's verification verdict back to `unknown`, clears the delivery evidence behind it and forgets its `mail_host` and `esp_provider`, because all of them belonged to the old mailbox; the next verification pass checks the new address and the provider is read again from the new domain. Steps already sent went to the old address and keep their history.
|
||||
|
||||
@@ -761,7 +761,7 @@ Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`
|
||||
| `custom_fields` | object | No | Replaces the custom-fields map. |
|
||||
| `subscribed` | boolean | No | Subscription status. |
|
||||
| `campaigns` | string[] | No | Set the full campaign membership (nil leaves as-is). |
|
||||
| `categories` | string[] | No | Set the full category list (nil leaves as-is). |
|
||||
| `categories` | string[] | No | Set the full list of label IDs (nil leaves as-is). |
|
||||
| `add_categories` | string[] | No | Diff-style add (ignored when `categories` is set). |
|
||||
| `remove_categories` | string[] | No | Diff-style remove (ignored when `categories` is set). |
|
||||
|
||||
@@ -865,7 +865,7 @@ Returns a `data` array plus a `pagination` envelope.
|
||||
|
||||
`GET /contacts/:id/timeline`
|
||||
|
||||
Returns the selected organization's merged activity feed for a contact: sends, opens, clicks (one per link, naming the link), replies, bounces, deliverability and suppression events, notes, meeting bookings, and lifecycle events (the contact's creation with its first-touch source, and every time it joined or left a campaign or a category). Every member with permission to view contacts receives the same timeline, regardless of who created the contact or its campaigns. A request with no selected organization returns `400`, and a contact outside the selected organization returns `404`.
|
||||
Returns the selected organization's merged activity feed for a contact: sends, opens, clicks (one per link, naming the link), replies, bounces, deliverability and suppression events, notes, meeting bookings, and lifecycle events (the contact's creation with its first-touch source, and every time it joined or left a campaign or gained or lost a label). Every member with permission to view contacts receives the same timeline, regardless of who created the contact or its campaigns. A request with no selected organization returns `400`, and a contact outside the selected organization returns `404`.
|
||||
|
||||
Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`
|
||||
|
||||
@@ -947,7 +947,7 @@ Returns a `data` array and the standard `pagination` envelope. Paginate by passi
|
||||
|
||||
A `form_submitted` event carries `form_id` and `form_name`. A `page_hit` event is a page view on your own site from a browser tied to the contact through an email-link ticket (see [Website tracking](/guides/website-tracking/)); `subject` is the page title, or its path when the page has none, and `page_hit` carries the full view: `url`, `path`, `title`, `referrer`, `referrer_domain`, `landing` (the first view of a session), the `utm_*` parameters, `device_type`, `os`, `browser`, `browser_version`, `device_brand`, `language`, `timezone`, `screen_width`, `screen_height`, and `country_code`, `region`, `city` when known.
|
||||
|
||||
Lifecycle events carry the name of what changed as it was at the time (`campaign_name`, or `category_id` plus `category_title`), so a later rename or deletion does not rewrite history. A `contact_created` event carries `source` (`manual`, `campaign`, `import`, `sheet_sync`, `api`, `form`, `automation`, `ai_assistant`, or `unknown` for contacts that predate attribution) and `source_detail` (the file, campaign, sheet, form, automation or API key name). The same values are on the contact itself as `source`, `source_detail` and `first_seen_at`, and never change after creation.
|
||||
Lifecycle events carry the name of what changed as it was at the time (`campaign_name`, or, for a label, `category_id` plus `category_title`), so a later rename or deletion does not rewrite history. A `contact_created` event carries `source` (`manual`, `campaign`, `import`, `sheet_sync`, `api`, `form`, `automation`, `ai_assistant`, or `unknown` for contacts that predate attribution) and `source_detail` (the file, campaign, sheet, form, automation or API key name). The same values are on the contact itself as `source`, `source_detail` and `first_seen_at`, and never change after creation.
|
||||
|
||||
## Get a contact's campaign state
|
||||
|
||||
@@ -1257,7 +1257,7 @@ Each condition names a `field`, an `operator`, and either a `value` (scalar oper
|
||||
| bool | `subscribed`, `suppressed`, `is_catch_all` | `is_true`, `is_false` | none |
|
||||
| date | `created_at`, `updated_at`, `last_sent_at`, `last_opened_at`, `last_clicked_at`, `last_replied_at` | `within_days`, `not_within_days` (`value` is a day count, 1 to 3650); `before`, `after` (`value` is `YYYY-MM-DD` or RFC 3339); `is_empty`, `is_not_empty` | see operators |
|
||||
| number | `campaign_count`, `emails_sent`, `emails_opened`, `emails_clicked`, `emails_replied`, `emails_bounced` | `equals`, `not_equals`, `gt`, `gte`, `lt`, `lte` | `value`, a whole number |
|
||||
| category | `category` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, category ids |
|
||||
| category | `category` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, label ids |
|
||||
| campaign | `campaign` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, campaign ids |
|
||||
| segment | `segment` | `in`, `not_in` | `values`, segment ids; at most five levels deep, no loops |
|
||||
|
||||
@@ -1287,7 +1287,7 @@ Counts the contacts an unsaved definition would match. Send `match` and `conditi
|
||||
{ "contacts": ["…", "…"], "mode": "include" }
|
||||
```
|
||||
|
||||
`mode` is `include` (pin in), `exclude` (pin out) or `auto` (clear the override). Up to 1,000 contact ids per call; ids outside the organization are ignored. Returns `{ "updated": n }`. The body also takes a [filter selection](#selecting-contacts-for-a-bulk-action) (`all`, `filters`, `exclude`) instead of `contacts`, for pinning everything a search matches.
|
||||
`mode` is `include` (pin in), `exclude` (pin out) or `auto` (clear the override). Up to 10,000 contact ids per call, or a filter selection of up to 250,000; ids outside the organization are ignored. Returns `{ "updated": n }`. The body also takes a [filter selection](#selecting-contacts-for-a-bulk-action) (`all`, `filters`, `exclude`) instead of `contacts`, for pinning everything a search matches.
|
||||
|
||||
`POST /segments/:id/members/lookup` takes `{ "contacts": [...] }` and returns `{ "data": { "<contact id>": "include" | "exclude" } }` for the contacts that carry an override.
|
||||
|
||||
|
||||
@@ -1123,7 +1123,7 @@ Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`.
|
||||
| `column_mapping` | array | Yes | Column-to-target mappings ([same shape as contact import](/api/reference/contacts/)). Validated on save, not on the next sync: a mapping with no `email` column, or a custom field whose name Warmbly cannot use, is a `400` here. |
|
||||
| `dedup` | string | No | Collision strategy: `skip`, `update`, or `create_duplicate`. |
|
||||
| `target_campaign_id` | uuid | No | Enrol new/updated leads into this campaign on each sync. |
|
||||
| `category_ids` | string[] | No | Categories to assign to synced leads. |
|
||||
| `category_ids` | string[] | No | Label IDs to put on synced leads. |
|
||||
| `segment_ids` | string[] | No | Segments every synced row is pinned into as a manual include override, on every run. Each id must name a segment in the organization or the save is a `400`; a segment deleted later is dropped from the run instead of failing it. |
|
||||
| `subscribed_default` | boolean | No | Default subscription state for new contacts. |
|
||||
| `label` | string | No | Friendly name. |
|
||||
@@ -1215,7 +1215,7 @@ Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`.
|
||||
| `dedup` | string | No | Collision strategy. |
|
||||
| `target_campaign_id` | uuid | No | New target campaign. |
|
||||
| `clear_campaign` | boolean | No | When `true`, unsets the target campaign. |
|
||||
| `category_ids` | string[] | No | Replacement category set. |
|
||||
| `category_ids` | string[] | No | Replacement set of label IDs. |
|
||||
| `segment_ids` | string[] | No | Replacement segment target set. An unknown id is a `400`; an empty array clears the targets. |
|
||||
| `subscribed_default` | boolean | No | Default subscription state. |
|
||||
| `label` | string | No | New label. |
|
||||
|
||||
@@ -201,7 +201,7 @@ The response is a `data` plus `pagination` envelope. Each item is a full message
|
||||
|
||||
`GET /unibox/thread/labels`
|
||||
|
||||
Returns the conversation labels (the workspace's categories) attached to a thread. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
|
||||
Returns the labels attached to a thread, drawn from the same workspace list as contact labels (managed through `/categories`). Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
|
||||
|
||||
| Parameter | In | Type | Description |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -223,14 +223,14 @@ The response wraps the labels in a `data` array.
|
||||
|
||||
`PUT /unibox/thread/labels`
|
||||
|
||||
Replaces the full conversation-label set on a thread, for the whole workspace. The body's `category_ids` is the desired set, so the call is idempotent and retries are naturally safe. Only the workspace's own categories are attached; an id belonging to another organization is dropped. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
|
||||
Replaces the full conversation-label set on a thread, for the whole workspace. The body's `category_ids` is the desired set, so the call is idempotent and retries are naturally safe. Only the workspace's own labels are attached; an id belonging to another organization is dropped. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
|
||||
|
||||
### Request body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `thread_id` | string | Yes | The thread to label. |
|
||||
| `category_ids` | string[] | No | The full desired set of category UUIDs. An empty array clears all labels. |
|
||||
| `category_ids` | string[] | No | The full desired set of label UUIDs. An empty array clears all labels. |
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -179,7 +179,7 @@ Baseline (always loads):
|
||||
The dev user's org always loads as a mid-flight workspace, not an empty shell:
|
||||
|
||||
- 4 warmed mailboxes on the shared worker, all in the premium warmup pool
|
||||
- Folders, tags, and categories with real bindings (mailbox tags, campaign folders, contact categories, inbox thread labels)
|
||||
- Folders, tags, and labels with real bindings (mailbox tags, campaign folders, labels on contacts and inbox threads)
|
||||
- ~30 contacts with titles and companies, a few unsubscribed or suppressed
|
||||
- An active 3-step campaign with 24 leads spread across the funnel (sent, opened, replied, bounced, queued), plus a draft campaign
|
||||
- 14 days of stats history (campaign sends, warmup volume, per-mailbox counts), always including sends today
|
||||
|
||||
@@ -44,7 +44,7 @@ Two other entry points:
|
||||
- a CRM pipeline with five stages, five deals across them, tasks, contact notes, and per-contact activity timelines
|
||||
- the analytics tables behind the deliverability and reporting views: deliverability events (opens, clicks, replies, bounces, complaints, unsubscribes), reply-intent classifications, a suppression list, and resolved and open mailbox errors
|
||||
- an org audit trail (campaign created and started, mailbox connected, member invited, deal created, key created) so the audit log has depth
|
||||
- reply templates, notifications (some unread), and labels everywhere they appear: folders (Outbound, Nurture), mailbox tags (VIP, Cold, Agency), and categories (Lead, Customer, Churn risk) bound to the campaigns, senders, several contacts, and inbox threads
|
||||
- reply templates, notifications (some unread), and labels everywhere they appear: folders (Outbound, Nurture), mailbox tags (VIP, Cold, Agency), and workspace labels (Lead, Customer, Churn risk) bound to the campaigns, senders, several contacts, and inbox threads
|
||||
- working credentials for every `smtp_imap` account in the database, including the older `make seed` fixtures, sealed with `CREDENTIALS_ENCRYPTION_KEY` so the worker can decrypt and use them
|
||||
- an [Advisor](/guides/advisor/) showcase (see below), and one evaluation run at the end of seeding so the recommendations exist before you log in
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ The panel resizes by dragging its inner edge, which also takes the keyboard once
|
||||
|
||||
## What you can ask
|
||||
|
||||
- Find, read, create, and delete contacts, edit fields, tags, subscription, and notes, and bulk-edit
|
||||
- Find, read, create, and delete contacts, edit fields, labels, subscription, and notes, and bulk-edit
|
||||
- Read a contact's activity timeline and the emails you sent them
|
||||
- List campaigns and stats, and list leads by status, which is how it finds leads that went cold
|
||||
- Create, edit, or delete campaigns, manage sender mailboxes and tracking domains, read logs, and edit sequence steps
|
||||
|
||||
@@ -16,11 +16,11 @@ Every mode takes a plain-language **instruction**, templated so you can drop eve
|
||||
| Classify | Picks exactly one of your labels | `ai_class` |
|
||||
| Extract fields | Pulls the fields you name out of the event text | One variable per field |
|
||||
|
||||
**Agent** is the default. Check which reversible actions it may take: add or remove a tag, label the email, create a task, create a deal, move a deal stage, set variables, and unsubscribe. It decides which fit, can chain several, and writes the details itself (task title, deal name, variable value). It only ever takes the actions you enable, and **never sends email or replies**. Billed one credit per step it takes.
|
||||
**Agent** is the default. Check which reversible actions it may take: add or remove a label, label the conversation, create a task, create a deal, move a deal stage, set variables, and unsubscribe. It decides which fit, can chain several, and writes the details itself (task title, deal name, variable value). It only ever takes the actions you enable, and **never sends email or replies**. Billed one credit per step it takes.
|
||||
|
||||
For tag and label actions, an optional **pool** limits which tags it may choose. An empty pool lets it use any of yours, and optionally create a new one when nothing fits.
|
||||
For the label actions, an optional **pool** limits which labels it may choose: the labels it can add or remove on the contact, and the conversation labels it can apply. An empty pool lets it use any of yours, and optionally create a new one when nothing fits.
|
||||
|
||||
On a Reply received trigger, an instruction like "If they ask about pricing, tag them and open a follow-up task; if they ask to stop, unsubscribe them" with those three actions enabled handles all three outcomes in one step.
|
||||
On a Reply received trigger, an instruction like "If they ask about pricing, label them and open a follow-up task; if they ask to stop, unsubscribe them" with those three actions enabled handles all three outcomes in one step.
|
||||
|
||||
**Classify** needs at least two labels and returns exactly one, stored in `ai_class`. A non-exact answer resolves to the closest match; if none is close, the raw answer is stored so you can see what happened in run history.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Automations
|
||||
description: "A visual flow builder: a trigger event connected to action steps across your integrations."
|
||||
---
|
||||
|
||||
An automation is a flow on a canvas: one **trigger** (a reply arrives, a meeting is booked) runs one or more **actions** (post to Slack, push to your CRM, tag a contact), with **IF conditions** in between to branch. No code, though an advanced mode accepts a free-form condition.
|
||||
An automation is a flow on a canvas: one **trigger** (a reply arrives, a meeting is booked) runs one or more **actions** (post to Slack, push to your CRM, label a contact), with **IF conditions** in between to branch. No code, though an advanced mode accepts a free-form condition.
|
||||
|
||||
<Mermaid
|
||||
chart={`
|
||||
@@ -97,12 +97,12 @@ Slack, Discord, and webhook actions take an optional message template. Slack and
|
||||
|
||||
| Built-in action | What it does |
|
||||
| --- | --- |
|
||||
| Create or update contact | Makes a contact from the event's fields, or enriches the one with that email, then tags it and enrols it in a campaign. See [Lead intake](#lead-intake) |
|
||||
| Create or update contact | Makes a contact from the event's fields, or enriches the one with that email, then labels it and enrols it in a campaign. See [Lead intake](#lead-intake) |
|
||||
| Add to campaign | Enrols the event's contact in a campaign; a finished campaign restarts through the usual launch checks (a refusal is noted in its activity log and the lead waits), one waiting for leads picks up straight away |
|
||||
|
||||
Saving an automation with either of these actions turns on the picked campaign's **Keep running for new leads** setting, so the campaign waits for the next run instead of finishing between them.
|
||||
| Add / remove a tag | Adds or removes a contact category |
|
||||
| Label the email | Applies inbox labels to the replied-on conversation |
|
||||
| Add / remove a label | Adds or removes a label on the contact |
|
||||
| Label the conversation | Applies labels to the conversation the contact replied on |
|
||||
| Create a task | Assigned to the workspace owner |
|
||||
| Create a deal | In a chosen pipeline and stage |
|
||||
| Move the deal stage | Moves the contact's most recent open deal in that pipeline |
|
||||
@@ -111,7 +111,7 @@ Saving an automation with either of these actions turns on the picked campaign's
|
||||
| Fire event | Publishes a `CUSTOM_EVENT` to the realtime gateway |
|
||||
| AI step / AI switch | An agent or single-shot AI node, and AI-decided routing. See [AI steps](/guides/ai-steps-in-automations/) |
|
||||
|
||||
Three constraints worth knowing: **Move the deal stage** does nothing if the contact has no open deal in that pipeline, **Unsubscribe** needs an event carrying a campaign, and **Label the email** works only on a Reply received automation, since it needs a thread to label.
|
||||
Three constraints worth knowing: **Move the deal stage** does nothing if the contact has no open deal in that pipeline, **Unsubscribe** needs an event carrying a campaign, and **Label the conversation** works only on a Reply received automation, since it needs a thread to label.
|
||||
|
||||
**Add to campaign** and every other contact action need a contact to act on: the event must carry `contact_id` or `contact_email`, which every Warmbly trigger does. On an inbound webhook, put **Create or update contact** first and the contact it writes becomes the event's contact for the steps after it.
|
||||
|
||||
@@ -120,7 +120,7 @@ Three constraints worth knowing: **Move the deal stage** does nothing if the con
|
||||
Any system that can send an HTTP request can create contacts in Warmbly without an API client: an **Inbound webhook** trigger followed by a **Create or update contact** action.
|
||||
|
||||
1. Pick the Inbound webhook trigger, save, and copy the URL.
|
||||
2. Add **Create or update contact**. Each field is a template over the JSON the caller sends: an email of `{{.email}}`, a first name of `{{.first_name}}`, a custom field `team_size` set to `{{.answers.team_size}}`. Pick the tags and the campaign the lead lands in.
|
||||
2. Add **Create or update contact**. Each field is a template over the JSON the caller sends: an email of `{{.email}}`, a first name of `{{.first_name}}`, a custom field `team_size` set to `{{.answers.team_size}}`. Pick the labels and the campaign the lead lands in.
|
||||
3. Point the sender at the URL. Zapier's *Webhooks by Zapier*, Make's *HTTP* module, n8n's *HTTP Request* node, a Typeform or Tally webhook, or your own code.
|
||||
|
||||
The action matches an existing contact by email, so re-sending the same lead updates it instead of duplicating it. A blank rendered value never erases a field the contact already has. **If the contact already exists** decides whether an existing contact is enriched and enrolled (the default) or left alone. New contacts carry `automation` as their first-touch source, with the automation's name as the detail.
|
||||
|
||||
@@ -142,7 +142,7 @@ Anything above `50`/day per cold mailbox needs positive reputation signals and a
|
||||
|
||||
### Adding leads
|
||||
|
||||
The **Leads** tab takes contacts four ways. **From contacts** opens a picker over the workspace contact list: search by name, email or company, filter by category, tick individual people or **Select loaded**, or **Select all matching** to add everyone the search returns, up to `50,000` at a time (narrow the search and repeat past that). Contacts already in the campaign are marked as leads and skipped. **Import** runs the file import wizard with this campaign preselected (its Options step can also pin the file into segments), **Sheet sync** attaches a Google Sheet, and **Add lead** creates a single new contact in the campaign. Any workspace member with contact access can add leads to any campaign in the workspace, whoever created it.
|
||||
The **Leads** tab takes contacts four ways. **From contacts** opens a picker over the workspace contact list: search by name, email or company, filter by label, tick individual people or **Select loaded**, or **Select all matching** to add everyone the search returns, up to `250,000` at a time (narrow the search and repeat past that). Contacts already in the campaign are marked as leads and skipped. **Import** runs the file import wizard with this campaign preselected (its Options step can also pin the file into segments), **Sheet sync** attaches a Google Sheet, and **Add lead** creates a single new contact in the campaign. Any workspace member with contact access can add leads to any campaign in the workspace, whoever created it.
|
||||
|
||||
**Segments** on the same toolbar links [segments](/guides/segments/) to the campaign as a live audience, up to 20 per campaign. Linking enrols every current member immediately and turns on **Keep running for new leads**, and contacts who enter a linked segment later are enrolled on their own, within a couple of minutes. An active campaign wakes to send to them (a campaign waiting for leads picks up straight away), a finished one restarts through the usual launch checks, and a paused one accumulates them for later. A contact who leaves a linked segment keeps their lead row, but detaching the segment itself takes its leads back out, so swapping one segment for another leaves the campaign with the new list rather than both. Leads the campaign has already emailed stay, as do any you added by hand and anyone a segment that stayed linked still covers; the dialog asks before a save that costs leads. See [linking a segment to a campaign](/guides/segments/#linking-a-segment-to-a-campaign).
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Indicators are workspace-scoped. Teammates see only your name, avatar, current p
|
||||
- Contact imports, edits, and deletes refresh contacts views for the whole team.
|
||||
- Mailbox health transitions (a warmup quarantine, say) flip status badges live.
|
||||
- The CRM is live end to end: moving a deal, ticking a task, or editing pipeline stages shows up immediately, and a deal being dragged carries a colored ring so you know it is in motion.
|
||||
- Mailbox tags, contact categories and campaign folders each have their own shared set per workspace, so a label anyone creates appears in everybody's pickers and chips as they make it.
|
||||
- Mailbox tags, labels and campaign folders each have their own shared set per workspace, so a label anyone creates appears in everybody's pickers and chips as they make it.
|
||||
- The audit log streams new entries, so the activity trail is itself a live feed.
|
||||
|
||||
**Events are permission-aware**: a member without inbox access never receives unibox events, and billing events reach only those who can manage billing. See [Team roles](/guides/team-roles/).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Contacts & CRM"
|
||||
description: "Import contacts, custom fields, categories, deals, and pipelines."
|
||||
description: "Import contacts, custom fields, labels, deals, and pipelines."
|
||||
---
|
||||
|
||||
Contacts are the people you reach out to; the CRM tracks what happens after they reply.
|
||||
@@ -13,7 +13,7 @@ Two routes share the same column-mapping screen: a file upload and an on-demand
|
||||
|
||||
1. **Upload.** The file goes up once and is read on the server, which measures every column over the whole file. Nothing is written to your contacts yet. **Download a sample file** gives you a CSV with every standard column if you are starting from scratch.
|
||||
2. **Map columns.** Each column shows how full it is across the file, a few of its values, and where it will go. **Find a column** and **Unmapped only** help with wide CRM exports, **First row is header** moves the first row between the headers and the data, and **Preview contacts** shows the first rows as the contacts they will become, with any row missing a usable address marked.
|
||||
3. **Review.** The whole file is checked before anything is written, and the result is one line: how many new contacts will be added, how many are already in your workspace, how many repeated rows are merged, and how many rows can't be imported (open it to see which and why). When some contacts already exist, choose **Leave as is** or **Update details** for them. **Add them to** puts everything from the file into segments, categories, and campaigns; the segment picker creates a segment on the spot, and offers one named after the file, so an import becomes an audience in one step. **More options** holds whether new contacts are subscribed. An import that would be refused as a whole, because it would put the workspace over its plan's contact limit or create more than 100 categories, says so here instead of after the upload, and the import button says exactly what will happen (`Import 1,102 new · update 118`).
|
||||
3. **Review.** The whole file is checked before anything is written, and the result is one line: how many new contacts will be added, how many are already in your workspace, how many repeated rows are merged, and how many rows can't be imported (open it to see which and why). When some contacts already exist, choose **Leave as is** or **Update details** for them. **Add them to** puts everything from the file into segments, labels, and campaigns; the segment picker creates a segment on the spot, and offers one named after the file, so an import becomes an audience in one step. **More options** holds whether new contacts are subscribed. An import that would be refused as a whole, because it would put the workspace over its plan's contact limit or create more than 100 labels, says so here instead of after the upload, and the import button says exactly what will happen (`Import 1,102 new · update 118`).
|
||||
4. **Import.** The import runs on the server in chunks, with live progress, its rate, and the time left. You can close the window: it keeps running, survives a refresh, and everyone in the workspace sees it finish. While it runs, an **Importing** chip on the Contacts toolbar shows how far it is and reopens it; **Stop** ends it early and keeps the rows already imported. The result reports imported, updated, skipped, and failed counts, lists the failed rows with their reasons, and **Download to fix** gives you every failed row as uploaded, under the file's own headers, with the reason in the last column, ready to correct and import again.
|
||||
|
||||
A file whose headers match one you imported before maps itself the way you confirmed last time, marked **Mapping remembered**. The Upload step lists the workspace's recent imports, finished or still running, and opening one shows its progress or result.
|
||||
@@ -25,7 +25,7 @@ You must map at least one column to **Email**.
|
||||
| Email | Required, and used to dedupe |
|
||||
| First name, Last name, Company, Phone | Standard identity fields |
|
||||
| Subscribed | `yes`, `true`, `1`, `subscribed` and their opposites (`no`, `false`, `0`, `unsubscribed`). Applies to new contacts and to existing ones you update; a value the importer cannot read fails only that row |
|
||||
| Categories | Category names, separated by commas or semicolons. Names you do not have yet are created, up to 100 new names per import |
|
||||
| Labels | Label names, separated by commas or semicolons. Names you do not have yet are created, up to 100 new names per import |
|
||||
| Custom field | Anything else: one of your existing custom fields, or a new one you name |
|
||||
|
||||
### Mapping into your custom fields
|
||||
@@ -34,7 +34,7 @@ The mapping menu lists the custom fields your workspace already has under **Your
|
||||
|
||||
A column whose header names an existing field is mapped to it before you open the menu. The match ignores case, spaces, underscores, dashes and dots, so a `company url` column lands on your `company_url` field in its stored spelling instead of starting a second field. Only one column is matched to each field, and standard fields (Email, Company, and so on) are matched first.
|
||||
|
||||
When the instance has TypeSafe configured, the columns no header matched are also placed by what their header means: `Job Title` onto your `Title` field, `Firmenname` onto Company, `Sector` onto `Industry`. Only a confident answer is applied, each field still goes to one column, and those rows are marked **Matched by meaning. Check it.** until you change them. It picks names, Company, Phone and your custom fields only: Subscribed and Categories change who gets mail and which categories exist, so they are never guessed. What is sent is the column headers, the kind of value each column holds and your field names, never a cell, and nothing at all unless the first row is clearly headers (no address, date or number in it, and a named Email column); see [data control](/development/data-control/#typesafe-judgments). A column of addresses under any header, or none, is mapped to Email either way.
|
||||
When the instance has TypeSafe configured, the columns no header matched are also placed by what their header means: `Job Title` onto your `Title` field, `Firmenname` onto Company, `Sector` onto `Industry`. Only a confident answer is applied, each field still goes to one column, and those rows are marked **Matched by meaning. Check it.** until you change them. It picks names, Company, Phone and your custom fields only: Subscribed and Labels change who gets mail and which labels exist, so they are never guessed. What is sent is the column headers, the kind of value each column holds and your field names, never a cell, and nothing at all unless the first row is clearly headers (no address, date or number in it, and a named Email column); see [data control](/development/data-control/#typesafe-judgments). A column of addresses under any header, or none, is mapped to Email either way.
|
||||
|
||||
Each custom mapping says what it will do: **Fills your existing field**, **Creates a new field**, or, when the name you typed differs from an existing field only in case or separators, **You already have `Industry`**, with **Use it** to switch to that field. **Keep N more as custom fields** uses the same matching, so the columns it claims land on your existing fields where it can.
|
||||
|
||||
@@ -43,7 +43,7 @@ Two columns may fill the same field, which is how a `Phone` and a `Mobile` colum
|
||||
<Callout type="info" title="Duplicates match on lowercased email, across the workspace">
|
||||
`Dana@Acme.com` and `dana@acme.com` are the same contact, and a contact belongs to the workspace, so an address a teammate already added counts as one you have. Choose **Leave as is** (their details stay untouched) or **Update details** (empty details are filled in from the file). The API also accepts `create_duplicate`, which updates too, since one address is one contact per workspace.
|
||||
|
||||
Skipping still adds the contact to the campaigns, segments, and categories the import targets: it leaves their fields alone, it does not leave them out of the list. If the same address appears twice in one file it becomes one contact, and the extra rows count as skipped with the line they repeat.
|
||||
Skipping still adds the contact to the campaigns, segments, and labels the import targets: it leaves their fields alone, it does not leave them out of the list. If the same address appears twice in one file it becomes one contact, and the extra rows count as skipped with the line they repeat.
|
||||
</Callout>
|
||||
|
||||
An address you already hold as a contact in another workspace you belong to cannot be added to this one, and that row fails with a reason saying so; the other workspace's contact is never changed by an import here.
|
||||
@@ -56,7 +56,7 @@ Problems with the mapping itself, an unnamed custom field or a name Warmbly cann
|
||||
|
||||
On the **Review** step, **Campaigns** enrols everyone in the file as leads of the campaigns you pick (started from a campaign's Leads tab, that campaign is shown as the fixed target), and **Segments** pins them into the [segments](/guides/segments/) you pick as manual includes, so they stay members whatever the segment's conditions say. A segment created from the picker has no conditions, so it holds exactly the contacts pinned into it. Started from a segment, that segment is always applied and shown as a fixed row; the picker below it adds more.
|
||||
|
||||
**From Google Sheets**, a sheet is a reusable **sync source** rather than a one-time upload. Connect it once (Warmbly reads only the tab you choose and never writes back), paste the spreadsheet ID from the URL between `/d/` and `/edit`, pick the tab, map columns, then set duplicate handling, a label, and optionally a campaign to enroll into, categories to apply and segments to pin into. **Nothing syncs automatically**: press **Sync now**, or save and sync immediately.
|
||||
**From Google Sheets**, a sheet is a reusable **sync source** rather than a one-time upload. Connect it once (Warmbly reads only the tab you choose and never writes back), paste the spreadsheet ID from the URL between `/d/` and `/edit`, pick the tab, map columns, then set duplicate handling, a label, and optionally a campaign to enroll into, labels to apply and segments to pin into. **Nothing syncs automatically**: press **Sync now**, or save and sync immediately.
|
||||
|
||||
Segment targets apply on every run, so a sheet you keep adding rows to keeps feeding the same audience. Opening **Sync sources** from a segment's member list lists only the sources feeding it and pre-targets a new one to it. A segment deleted later is dropped from the run rather than stopping it.
|
||||
|
||||
@@ -100,7 +100,7 @@ See [Personalization & expressions](/guides/expressions/) for the full templatin
|
||||
|
||||
## Filtering the list
|
||||
|
||||
A filter bar sits above the contact list. **Category**, **Segment**, **Status** and **Campaign** are always there; open one, tick values, and the list updates immediately with the matching count next to the bar. **Add filter** adds a custom-field condition (field, contains/is/starts with/ends with, value), a date-added or last-updated range, a number-of-campaigns range, the address verification verdict, the [email provider](#email-provider) and, on a campaign's Leads tab, lead status and engagement. Each active filter is a pill you can reopen to change or remove with its cross; **Clear** drops them all, and **Save as segment** turns the current set into a [segment](/guides/segments/). Free-text search, sort and the column chooser stay in the toolbar.
|
||||
A filter bar sits above the contact list. **Label**, **Segment**, **Status** and **Campaign** are always there; open one, tick values, and the list updates immediately with the matching count next to the bar. **Add filter** adds a custom-field condition (field, contains/is/starts with/ends with, value), a date-added or last-updated range, a number-of-campaigns range, the address verification verdict, the [email provider](#email-provider) and, on a campaign's Leads tab, lead status and engagement. Each active filter is a pill you can reopen to change or remove with its cross; **Clear** drops them all, and **Save as segment** turns the current set into a [segment](/guides/segments/). Free-text search, sort and the column chooser stay in the toolbar.
|
||||
|
||||
Free-text search matches first name, last name, email, company and phone. Every word you type has to match one of those, so `Test Demo` finds the contact whose first name is Test and last name is Demo, and `Demo Acme` finds everyone named Demo at Acme. Words can be in any order, and only the first six count.
|
||||
|
||||
@@ -122,7 +122,7 @@ Each contact's avatar carries a small badge with the logo of whoever hosts their
|
||||
|
||||
A contact on a company domain shows that company's logo in place of their initials, and the **Company** column shows it beside the company name; when no company is on file, the column shows the domain their address is on. Personal inboxes (Gmail, Outlook.com, Yahoo and the like) keep their initials. Logos are on for Warmbly Cloud and off on a self-hosted instance unless its operator turns them on, because the browser fetches each one from DuckDuckGo; see [data control](/development/data-control/#outbound-calls).
|
||||
|
||||
It shows on the contacts page and on a campaign's Leads tab alike. You can sort by it from **Sort**, filter with **Add filter** > **Email provider** (including **Unknown or not checked yet**), select every match and act on them in bulk (add them to a campaign, tag them, export them), or save the filter as a segment. Campaign [ESP matching](/guides/campaigns/) uses the same data to pair each lead with a same-provider mailbox.
|
||||
It shows on the contacts page and on a campaign's Leads tab alike. You can sort by it from **Sort**, filter with **Add filter** > **Email provider** (including **Unknown or not checked yet**), select every match and act on them in bulk (add them to a campaign, label them, export them), or save the filter as a segment. Campaign [ESP matching](/guides/campaigns/) uses the same data to pair each lead with a same-provider mailbox.
|
||||
|
||||
## Selecting rows
|
||||
|
||||
@@ -130,35 +130,35 @@ Tick a row's checkbox to select it, or the one in the table header to select eve
|
||||
|
||||
When more contacts match than are loaded, a bar appears under the header: **Select all N matching**. Clicking it hands the whole filtered set to the next action, however many pages that is, and the selection bar counts the full number rather than the loaded rows. Unticking a row afterwards takes just that contact out and the count follows. **Clear selection** in the bar, or the header checkbox, drops back to nothing.
|
||||
|
||||
The set is the one the list is showing: search, filters, the subscription facet, and the campaign or segment the list is scoped to all narrow it. Changing any of them clears the selection, because it would no longer mean what it did when you made it. A selection past `50,000` contacts is refused rather than half-applied; add a filter and work through it in parts.
|
||||
The set is the one the list is showing: search, filters, the subscription facet, and the campaign or segment the list is scoped to all narrow it. Changing any of them clears the selection, because it would no longer mean what it did when you made it. A selection past `250,000` contacts is refused rather than half-applied; add a filter and work through it in parts.
|
||||
|
||||
Every bulk action reads the selection: **Edit**, **Segment**, **Remove from segment**, **Remove from campaign**, **Research**, **Verify**, **Mark deliverable** and **Delete**. **Push to CRM** is the exception: it calls the CRM once per contact while you wait, so it stays capped at `500` at a time.
|
||||
Every bulk action reads the selection: **Edit**, **Segment**, **Remove from segment**, **Remove from campaign**, **Research**, **Verify**, **Mark deliverable** and **Delete**. **Push to CRM** and **Research** are the exceptions: a push calls the CRM once per contact while you wait, and each research run spends AI credits, so both stay capped at `500` at a time.
|
||||
|
||||
**Select all matching** is in the **From contacts** picker too, on a campaign's Leads tab and a segment page, so a whole search can be added as leads or members in one step.
|
||||
|
||||
## Editing one contact
|
||||
|
||||
Clicking a row opens the contact's panel. Its **Details** tab edits the fields the contact is made of: name, email address, company, phone, subscription, campaigns, categories and custom fields. Nothing is sent until **Save**, **Discard** puts the panel back to the stored values, and closing it with unsaved edits asks first.
|
||||
Clicking a row opens the contact's panel. Its **Details** tab edits the fields the contact is made of: name, email address, company, phone, subscription, campaigns, labels and custom fields. Nothing is sent until **Save**, **Discard** puts the panel back to the stored values, and closing it with unsaved edits asks first.
|
||||
|
||||
The email address can be changed here, which is the right move when someone's address was mistyped on import or they moved to a new domain. It is stored lowercased and stripped of any display name, and it has to be free: an address another contact already holds is refused, because merging two people's campaign history is not something the edit could undo. A new address also clears the contact's [verification](/guides/deliverability/#address-verification) verdict and everything the platform had observed about the old mailbox, so the background check starts the new address from scratch on its next pass, and the recipient provider Warmbly matches senders against is worked out again from the new domain. Emails already sent went to the old address and stay in the timeline as they happened.
|
||||
|
||||
## Editing many contacts at once
|
||||
|
||||
Tick rows in any contact list, a segment's members or a campaign's leads, and the selection bar's **Edit** opens a bulk panel. It can add or remove campaigns, add or remove categories, force a subscription state, and queue custom-field operations (add, edit, delete, rename) that run on every selected contact (the key box suggests your existing fields as you type), including a **Select all matching** selection that reaches past the loaded pages.
|
||||
Tick rows in any contact list, a segment's members or a campaign's leads, and the selection bar's **Edit** opens a bulk panel. It can add or remove campaigns, add or remove labels, force a subscription state, and queue custom-field operations (add, edit, delete, rename) that run on every selected contact (the key box suggests your existing fields as you type), including a **Select all matching** selection that reaches past the loaded pages.
|
||||
|
||||
Nothing is applied while you build it up. The header names the list the selection came from, and a **Will apply** strip above the buttons lists every queued change as a chip you can take back one at a time before pressing Apply. A field operation missing its key, or its value where one is needed, is marked and skipped rather than sent half-finished.
|
||||
|
||||
## Categories
|
||||
## Labels
|
||||
|
||||
Colored labels that group and filter contacts (`Warm lead`, `Conference 2026`, `Enterprise`), behaving like tags. The same picker appears in bulk edit, a contact's Details tab, the new-contact dialog, the filters, and the import and sync wizards.
|
||||
Colored labels that group and filter contacts (`Warm lead`, `Conference 2026`, `Enterprise`). The same list labels [inbox conversations](/guides/unibox/#labels) and is what [forms](/guides/forms/) file their submissions under. The same picker appears in bulk edit, a contact's Details tab, the new-contact dialog, the filters, and the import and sync wizards.
|
||||
|
||||
It supports type-ahead search, and typing an unmatched name offers **Create** to add and select it in one step. Each category keeps its color everywhere its chip appears.
|
||||
It supports type-ahead search, and typing an unmatched name offers **Create** to add and select it in one step. Each label keeps its color everywhere its chip appears.
|
||||
|
||||
Categories belong to the workspace, not to whoever made them: every teammate sees the same list and can file contacts under it. **Contacts > Categories** lists every category with a live contact count. From there you can create one, rename it, change its color, delete it (contacts are kept; the label is removed from them and from inbox threads), or click a row to open the contact list filtered to it.
|
||||
Labels belong to the workspace, not to whoever made them: every teammate sees the same list and can label contacts with it. **Contacts > Labels** lists every label with a live contact count. From there you can create one, rename it, change its color, delete it (contacts are kept; the label is removed from them and from inbox threads), or click a row to open the contact list filtered to it.
|
||||
|
||||
Categories also drive automation: a sequence can run **Add tag** or **Remove tag** as a contact moves through a flow, so a label can be applied automatically on a positive reply. **Add to segment** and **Remove from segment** do the same for [segments](/guides/segments/).
|
||||
Labels also drive automation: a sequence can run **Add label** or **Remove label** as a contact moves through a flow, so a label can be applied automatically on a positive reply. **Add to segment** and **Remove from segment** do the same for [segments](/guides/segments/).
|
||||
|
||||
To turn labels and activity into a reusable audience, build a [segment](/guides/segments/): a saved set of conditions over contacts (categories, fields, campaign activity, engagement) that you can browse and add to a campaign in one step.
|
||||
To turn labels and activity into a reusable audience, build a [segment](/guides/segments/): a saved set of conditions over contacts (labels, fields, campaign activity, engagement) that you can browse and add to a campaign in one step.
|
||||
|
||||
## Where a contact came from
|
||||
|
||||
@@ -182,11 +182,11 @@ A new contact is also an event. `contact.created` goes to your [webhooks](/guide
|
||||
|
||||
## Activity timeline
|
||||
|
||||
The **Activity** tab of a contact is one workspace-wide feed, newest first, of everything Warmbly knows about them. Every member with permission to view contacts sees the same timeline, regardless of who created the contact or its campaigns. It includes every campaign email sent, opened, clicked, replied to or bounced (with the campaign, step, subject and sending mailbox), replies with their classified intent, deliverability and suppression events, notes, meetings, and the contact's lifecycle: when it was created and how, and each time it joined or left a campaign or a category.
|
||||
The **Activity** tab of a contact is one workspace-wide feed, newest first, of everything Warmbly knows about them. Every member with permission to view contacts sees the same timeline, regardless of who created the contact or its campaigns. It includes every campaign email sent, opened, clicked, replied to or bounced (with the campaign, step, subject and sending mailbox), replies with their classified intent, deliverability and suppression events, notes, meetings, and the contact's lifecycle: when it was created and how, and each time it joined or left a campaign or gained or lost a label.
|
||||
|
||||
Opens appear once per event, not once per email: a second open from another device is its own row. Opens show the device and mail client they were read on, such as **iPhone · Apple Mail app** or **Gmail · device hidden**, and clicks the device and browser the link opened in, with the city and country when known (see [How an open was read](/guides/analytics/#how-an-open-was-read)); expanded, the operating system, browser and full location, and why a device is hidden when a mail provider's image proxy fetched the email. The Overview tab sums this up under **How they read**: each client and device the contact's opens came from, with the count and the latest. A click names the link: the row reads **Clicked Pricing** and, expanded, shows the link's text, its full URL, the UTM source, medium, campaign and content it carried, and the browser. Every link in an email is tracked on its own, so two links clicked are two rows. Opens and clicks that came from a machine rather than the person (a mail privacy proxy, a security gateway that follows every link at delivery) carry an **auto** badge, and the expanded row says which rule caught them; see [Link tracking and UTM parameters](/guides/campaigns/#link-tracking-and-utm-parameters).
|
||||
|
||||
Filter chips narrow the feed (**Emails**, **Replies**, **Deliv.**, **Notes**, **Meetings**, **Campaigns**, **Lifecycle**), the search box matches subjects, campaigns, steps, mailboxes, categories, reasons and, for clicks, the link's text, URL and UTM values, and the date picker bounds it. Each row stays to one line until you click it; expanded, it shows every detail the event carries. The feed updates live as teammates and the schedulers write to it.
|
||||
Filter chips narrow the feed (**Emails**, **Replies**, **Deliv.**, **Notes**, **Meetings**, **Campaigns**, **Lifecycle**), the search box matches subjects, campaigns, steps, mailboxes, labels, reasons and, for clicks, the link's text, URL and UTM values, and the date picker bounds it. Each row stays to one line until you click it; expanded, it shows every detail the event carries. The feed updates live as teammates and the schedulers write to it.
|
||||
|
||||
At the top of the tab sits the campaign panel: for each campaign the contact is in, its flow with this contact's progress, the lead status, and what the scheduler will do next. See [Campaigns](/guides/campaigns/) for how the next action is worked out and what its states mean.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: "Forms"
|
||||
description: "Build a hosted lead-capture form, style it to match your site, embed it anywhere, and turn every submission into a contact."
|
||||
---
|
||||
|
||||
Forms turn website visitors into contacts. You build a form in the dashboard, publish it to a hosted page, and either share the link or embed the form on any website. Every submission is stored, and when it carries an email address it creates or updates a contact, files it under the categories you chose, and can drop it straight into a campaign.
|
||||
Forms turn website visitors into contacts. You build a form in the dashboard, publish it to a hosted page, and either share the link or embed the form on any website. Every submission is stored, and when it carries an email address it creates or updates a contact, applies the labels you chose, and can drop it straight into a campaign.
|
||||
|
||||
Open **Forms** in the sidebar. Viewing takes the same permission as viewing contacts; building and publishing take the manage-contacts permission.
|
||||
|
||||
@@ -58,7 +58,7 @@ The **Design** tab styles the form while the canvas updates live. The defaults a
|
||||
The **Settings** tab controls what a submission does:
|
||||
|
||||
- **Success message** is shown after submitting, or set a **redirect URL** to send the visitor to your own thank-you page instead.
|
||||
- **Add to categories** files every submitted contact under the categories you pick, for example "Website leads".
|
||||
- **Add to labels** puts the labels you pick on every submitted contact, for example "Website leads".
|
||||
- **Add to campaign** enrolls new contacts as leads in the campaign you pick. Sending still follows the campaign's own schedule, limits and windows; a form never causes immediate mail. Picking a campaign turns on its **Keep running for new leads** setting, so it waits between submissions instead of finishing; a campaign that had already finished restarts when a lead arrives, through the usual launch checks, and a refused restart is noted in its activity log while the lead waits.
|
||||
- **Spam protection** and **allowed embed domains** are covered below.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Triggers poll on your scenario's schedule. Make remembers what it has seen, so a
|
||||
| **Campaigns** | Create, Update, Start, Stop, Delete |
|
||||
| **Templates** | Create, Update, Delete, Render Reply Template |
|
||||
| **Meetings** | Log Meeting, Delete Meeting |
|
||||
| **Organization** | Create Contact Category, Mailbox Tag, or Campaign Folder |
|
||||
| **Organization** | Create Contact Category (a contact label), Mailbox Tag, or Campaign Folder |
|
||||
|
||||
Campaign, mailbox, pipeline, stage, template, and contact fields use live dropdowns from your workspace, so you pick real records instead of pasting ids.
|
||||
|
||||
@@ -56,7 +56,7 @@ Pair a search with an action for idempotent flows: Find Contact, then Create or
|
||||
Facebook and Instagram Lead Ads, LinkedIn Lead Gen Forms and TikTok Lead Generation all deliver new form submissions to Make in real time, which makes Make the shortest route from an ad to a follow-up sent from your own mailbox.
|
||||
|
||||
1. **Trigger:** *Facebook Lead Ads: Watch Leads* (or the LinkedIn or TikTok equivalent), picking the Page and form.
|
||||
2. **Module:** Warmbly *Create or Update Contact*. Map the form's email and name fields, put every other question into a custom field, and pick the categories. Then *Add to Campaign* with the campaign that follows up. Re-running the same lead updates the contact rather than duplicating it.
|
||||
2. **Module:** Warmbly *Create or Update Contact*. Map the form's email and name fields, put every other question into a custom field, and pick the labels. Then *Add to Campaign* with the campaign that follows up. Re-running the same lead updates the contact rather than duplicating it.
|
||||
|
||||
Prefer to keep the mapping inside Warmbly? Use the *HTTP: Make a request* module to POST the lead as JSON to an [inbound webhook automation](/guides/automations/#lead-intake) instead. The automation's **Create or update contact** action maps the JSON keys onto contact fields with templates, and the same flow can tag, notify Slack and open a task.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Both deliver the same JSON body: a delivery id, the event type and the full even
|
||||
|
||||
Two routes, depending on where you want the field mapping to live.
|
||||
|
||||
**Mapping in n8n.** An HTTP Request node calling `POST /v1/contacts` with the contact's fields, `categories` and `campaigns`. The write is an upsert by email, so re-running a workflow updates the contact instead of duplicating it.
|
||||
**Mapping in n8n.** An HTTP Request node calling `POST /v1/contacts` with the contact's fields, its labels (`categories`) and `campaigns`. The write is an upsert by email, so re-running a workflow updates the contact instead of duplicating it.
|
||||
|
||||
**Mapping in Warmbly.** Create an automation with the **Inbound webhook** trigger, copy its URL, and POST the raw payload to it from an HTTP Request node. The automation's **Create or update contact** action maps the JSON keys onto contact fields with templates (`{{.email}}`, `{{.answers.company}}`), tags the contact and enrols it in a campaign, and the same flow can notify Slack or open a task. See [Lead intake](/guides/automations/#lead-intake). This route needs no API key: the URL is the credential.
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: Segments
|
||||
description: Save reusable audiences built from contact fields, categories, campaign activity and email engagement, then add them to campaigns in one step.
|
||||
description: Save reusable audiences built from contact fields, labels, campaign activity and email engagement, then add them to campaigns in one step.
|
||||
---
|
||||
|
||||
A segment is a saved audience: a set of conditions over your contacts plus any contacts you pin in or out by hand. A segment says which contacts belong together; a campaign says what happens to them. Membership is evaluated live, so a contact that starts matching (a new category, a reply, a bounce) is in the segment the next time anyone looks, with no rebuild step.
|
||||
A segment is a saved audience: a set of conditions over your contacts plus any contacts you pin in or out by hand. A segment says which contacts belong together; a campaign says what happens to them. Membership is evaluated live, so a contact that starts matching (a new label, a reply, a bounce) is in the segment the next time anyone looks, with no rebuild step.
|
||||
|
||||
Segments live under **Contacts > Segments**, next to **All contacts** and **Categories**, and need the same permissions as contacts: **View contacts** to browse, **Manage contacts** to create, edit or delete.
|
||||
Segments live under **Contacts > Segments**, next to **All contacts** and **Labels**, and need the same permissions as contacts: **View contacts** to browse, **Manage contacts** to create, edit or delete.
|
||||
|
||||
## Building a segment
|
||||
|
||||
@@ -19,7 +19,7 @@ Give the segment a name and a color, then add conditions. Each condition is a fi
|
||||
| Contact | Subscribed, on the suppression list, catch-all domain | is yes, is no |
|
||||
| Contact | Source, verification status, email provider, email provider family | is any of, is none of |
|
||||
| Contact | Created, updated | in the last N days, not in the last N days, after, before |
|
||||
| Contact | Category | has any of, has none of, has none, has any |
|
||||
| Contact | Label | has any of, has none of, has none, has any |
|
||||
| Company | Company name | the text operators above |
|
||||
| Campaign activity | In campaign | is in any of, is in none of, is in no campaign, is in a campaign |
|
||||
| Campaign activity | Number of campaigns | is, is not, more than, at least, less than, at most |
|
||||
@@ -52,7 +52,7 @@ A segment with no conditions at all is a plain list: it holds exactly the contac
|
||||
|
||||
## Using a segment
|
||||
|
||||
- **Browse**: a segment page lists its current members with the same table, filters, detail drawer and bulk actions as the contacts page, including [**Select all matching**](/guides/contacts-crm/#selecting-rows) so an action covers every member and not only the rows loaded so far, for selections up to `50,000` contacts.
|
||||
- **Browse**: a segment page lists its current members with the same table, filters, detail drawer and bulk actions as the contacts page, including [**Select all matching**](/guides/contacts-crm/#selecting-rows) so an action covers every member and not only the rows loaded so far, for selections up to `250,000` contacts.
|
||||
- **Add to campaign**: enrols every current member as a lead of the campaign you pick, from the segment page or with **From segment** on a campaign's Leads tab. Contacts already in that campaign are skipped, and a running campaign wakes up to schedule the new leads. This is a snapshot: contacts who join the segment later are not added until you run it again. To keep a campaign fed automatically, [link the segment](#linking-a-segment-to-a-campaign) instead.
|
||||
- **Link to campaign**: attaches the segment to a campaign as a live audience, so contacts who join the segment later become leads on their own. See [below](#linking-a-segment-to-a-campaign).
|
||||
- **New campaign**: pick the segment as a lead list on the first step of **Campaigns** > **New campaign**, which shows how many leads it holds and how long the mailbox pool needs to reach them. See [create a campaign](/guides/campaigns/#create-a-campaign).
|
||||
@@ -93,15 +93,15 @@ Everything above can be driven from the [API](/api/reference/contacts/#segments)
|
||||
|
||||
API calls address a segment by its ID. It is shown at the bottom of the segment page header (click it to copy), in the **Copy segment ID** entry of a segment's row menu on the Segments tab, and in every segment the API returns. Reads take the `READ_CONTACTS` key scope, writes `WRITE_CONTACTS`, and enrolling into a campaign `WRITE_CAMPAIGNS`.
|
||||
|
||||
<Callout type="info" title="Segments and categories">
|
||||
Categories are labels you put on a contact. Segments are rules that read those labels (and everything else) to decide who belongs. Use a category to mark a fact about a contact, and a segment to describe an audience.
|
||||
<Callout type="info" title="Segments and labels">
|
||||
Labels are something you put on a contact. Segments are rules that read those labels (and everything else) to decide who belongs. Use a label to mark a fact about a contact, and a segment to describe an audience.
|
||||
</Callout>
|
||||
|
||||
## Limits
|
||||
|
||||
- 200 segments per workspace
|
||||
- 50 conditions per segment, 200 values per list condition
|
||||
- 1,000 contacts per manual add or remove request
|
||||
- 10,000 ticked contacts, or `250,000` through **Select all matching**, per manual add or remove
|
||||
|
||||
## Where to go next
|
||||
|
||||
|
||||
@@ -127,10 +127,10 @@ Upload attachments for a step below its composer by dragging and dropping files
|
||||
|
||||
### Action steps
|
||||
|
||||
A step can perform an action instead of sending: add or remove a tag, label email, create a task, create a deal or move its stage, unsubscribe the contact, notify (fires your webhooks and integrations), run an automation, or switch. They connect like email steps and work best at the end of a reply branch, for example creating a deal and notifying your team on a positive reply.
|
||||
A step can perform an action instead of sending: add or remove a label, label the conversation, create a task, create a deal or move its stage, unsubscribe the contact, notify (fires your webhooks and integrations), run an automation, or switch. They connect like email steps and work best at the end of a reply branch, for example creating a deal and notifying your team on a positive reply.
|
||||
|
||||
<Callout type="info">
|
||||
**Label email** is reply-only. It labels the conversation the contact replied on, so place it on a reply branch; anywhere else it is a no-op because there is no thread to label.
|
||||
**Label conversation** is reply-only. It labels the conversation the contact replied on, so place it on a reply branch; anywhere else it is a no-op because there is no thread to label.
|
||||
</Callout>
|
||||
|
||||
### Switch steps
|
||||
|
||||
@@ -55,7 +55,7 @@ Roles are workspace data. Every workspace starts with three seeded roles that ar
|
||||
| Area | Capability | Allows |
|
||||
| --- | --- | --- |
|
||||
| **Data** | View / manage campaigns | Read settings, sequences, analytics / create, edit, archive |
|
||||
| | View / manage contacts | Read contacts, segments, tags / create, edit, delete |
|
||||
| | View / manage contacts | Read contacts, segments, labels / create, edit, delete |
|
||||
| | Manage sequences | Edit step content and spacing |
|
||||
| | View analytics | Deliverability and engagement reports |
|
||||
| | Use integrations | Push contacts and deals to connected tools |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Unibox"
|
||||
description: "A unified inbox across all connected mailboxes, with categories and threading."
|
||||
description: "A unified inbox across all connected mailboxes, with labels and threading."
|
||||
---
|
||||
|
||||
One inbox for every connected mailbox. Read, sort, and reply from a single screen instead of logging into each account. Three columns: a **scope rail** for picking what to look at, a **conversation list**, and a **thread view** where you read and reply.
|
||||
@@ -133,14 +133,14 @@ Recognized quoted history collapses behind **Show quoted text**. Click it to rea
|
||||
|
||||
Every open message has a details toggle on its recipient line, the `to` under the sender's name, and the sender's name and address open the same panel. It shows the message envelope in place: every From, Reply-To, To, Cc and Bcc address, when it was sent and, if that differs, when the mailbox received it, both in your own time zone, the mailbox and folder it lives in, its size, and the Message-ID and In-Reply-To headers. Click any address or identifier to copy it. The info icon next to Reply and Forward opens the same panel, including on a message that is still collapsed.
|
||||
|
||||
## Categories and labels
|
||||
## Labels
|
||||
|
||||
Categories are the workspace's conversation labels, shared with the rest of Warmbly, so `Interested` means the same thing on a contact as it does here. They are shared with the rest of the team too: a category anyone creates is available to everyone, and a conversation one member labels shows that label to the next. Label with the tag button in the conversation header or `c`: search existing categories, tick what applies, or type a name and **Create**. A conversation can carry several.
|
||||
Labels are one workspace list, used on [contacts](/guides/contacts-crm/#labels), on conversations here and by [forms](/guides/forms/), so `Interested` means the same thing on a contact as it does on a conversation. The list is shared with the rest of the team too: a label anyone creates is available to everyone, and a conversation one member labels shows that label to the next. Label with the tag button in the conversation header or `c`: search existing labels, tick what applies, or type a name and **Create**. A conversation can carry several.
|
||||
|
||||
Labeled conversations show colored chips on the row and in the header, and each category appears in the rail with its own count for one-click filtering. Labels you apply are never touched by the system. [Automatic inbox tagging](/guides/inbox-tagging/) adds its own labels alongside them, additively, and never removes one a person applied.
|
||||
Labeled conversations show colored chips on the row and in the header, and each label appears in the rail with its own count for one-click filtering. Labels you apply are never touched by the system. [Automatic inbox tagging](/guides/inbox-tagging/) adds its own labels alongside them, additively, and never removes one a person applied.
|
||||
|
||||
<Callout type="info" title="Categories vs tags">
|
||||
Categories label conversations. Tags label the mailboxes themselves (grouping accounts by client or domain). Both filter from the rail, but they describe different things.
|
||||
<Callout type="info" title="Labels vs mailbox tags">
|
||||
Labels mark conversations and contacts. Mailbox tags mark the mailboxes themselves (grouping accounts by client or domain). Both filter from the rail, but they describe different things.
|
||||
</Callout>
|
||||
|
||||
## Read state
|
||||
|
||||
@@ -18,7 +18,7 @@ The data is split into groups. Every export includes **Workspace**; the rest are
|
||||
| Group | Contents |
|
||||
|-------|----------|
|
||||
| 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 |
|
||||
| Contacts | Contacts, labels, 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, 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 |
|
||||
|
||||
@@ -37,7 +37,7 @@ Triggers poll on Zapier's schedule (frequency depends on your plan). Zapier reme
|
||||
| **Campaigns** | Create, Update, Start, Stop, Delete |
|
||||
| **Templates** | Create, Update, Delete, Render Reply Template |
|
||||
| **Meetings** | Log Meeting, Delete Meeting |
|
||||
| **Organization** | Create Contact Category, Mailbox Tag, or Campaign Folder |
|
||||
| **Organization** | Create Contact Category (a contact label), Mailbox Tag, or Campaign Folder |
|
||||
|
||||
Campaign, mailbox, pipeline, stage, and contact fields use live dropdowns from your workspace, so you pick real records instead of pasting ids.
|
||||
|
||||
@@ -56,7 +56,7 @@ Pair a search with an action for idempotent flows: Find Contact, then Create or
|
||||
Facebook and Instagram Lead Ads, LinkedIn Lead Gen Forms and TikTok Lead Generation all deliver new form submissions to Zapier in real time, which makes Zapier the shortest route from an ad to a follow-up sent from your own mailbox.
|
||||
|
||||
1. **Trigger:** *Facebook Lead Ads: New Lead* (or the LinkedIn or TikTok equivalent), picking the Page and form.
|
||||
2. **Action:** Warmbly *Create or Update Contact*. Map the form's email and name fields, put every other question into a custom field, and pick the categories. Then *Add to Campaign* with the campaign that follows up. Re-running the same lead updates the contact rather than duplicating it.
|
||||
2. **Action:** Warmbly *Create or Update Contact*. Map the form's email and name fields, put every other question into a custom field, and pick the labels. Then *Add to Campaign* with the campaign that follows up. Re-running the same lead updates the contact rather than duplicating it.
|
||||
|
||||
Prefer to keep the mapping inside Warmbly? Use *Webhooks by Zapier: POST* to an [inbound webhook automation](/guides/automations/#lead-intake) instead, sending the lead as JSON. The automation's **Create or update contact** action maps the JSON keys onto contact fields with templates, and the same flow can tag, notify Slack and open a task.
|
||||
|
||||
|
||||
+15
-15
@@ -7256,7 +7256,7 @@
|
||||
],
|
||||
"operationId": "contacts_bulk_update",
|
||||
"summary": "Bulk update contacts",
|
||||
"description": "Applies one set of edits across a selection of contacts (up to 1000 by ID, or everything a filter matches, up to 50000): add/remove campaigns and categories, custom-field operations, and subscription. Scope `BULK_CONTACTS`.",
|
||||
"description": "Applies one set of edits across a selection of contacts (up to 10000 by ID, or everything a filter matches, up to 250000): add/remove campaigns and categories, custom-field operations, and subscription. Scope `BULK_CONTACTS`.",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
@@ -7292,7 +7292,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "A selection that names nothing, an explicit list over 1000 or an exclusion list over 50000 (`too_many_contacts`), a select-all with no filters, a filter matching no contacts, or a filter matching more than 50000 (`selection_too_large`).",
|
||||
"description": "A selection that names nothing, an explicit list over 10000 or an exclusion list over 250000 (`too_many_contacts`), a select-all with no filters, a filter matching no contacts, or a filter matching more than 250000 (`selection_too_large`).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -7339,7 +7339,7 @@
|
||||
],
|
||||
"operationId": "contacts_bulk_delete",
|
||||
"summary": "Bulk delete contacts",
|
||||
"description": "Deletes a selection of contacts: up to 1000 by ID, or everything a filter matches (up to 50000). Scope `BULK_CONTACTS`.",
|
||||
"description": "Deletes a selection of contacts: up to 10000 by ID, or everything a filter matches (up to 250000). Scope `BULK_CONTACTS`.",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
@@ -7352,7 +7352,7 @@
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"description": "A JSON array of contact ID strings (1 to 1000), or a ContactSelection object naming a filter.",
|
||||
"description": "A JSON array of contact ID strings (1 to 10000), or a ContactSelection object naming a filter.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -7360,7 +7360,7 @@
|
||||
{
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 1000,
|
||||
"maxItems": 10000,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
@@ -7379,7 +7379,7 @@
|
||||
"description": "Contacts deleted."
|
||||
},
|
||||
"400": {
|
||||
"description": "A selection that names nothing, an explicit list over 1000 or an exclusion list over 50000 (`too_many_contacts`), a select-all with no filters, a filter matching no contacts, or a filter matching more than 50000 (`selection_too_large`).",
|
||||
"description": "A selection that names nothing, an explicit list over 10000 or an exclusion list over 250000 (`too_many_contacts`), a select-all with no filters, a filter matching no contacts, or a filter matching more than 250000 (`selection_too_large`).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -19090,7 +19090,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "A selection that names nothing, an exclusion list over 50000 or one resolving to more than 500 contacts (`too_many_contacts`), a select-all with no filters, a filter matching no contacts, or a filter matching more than 50000 (`selection_too_large`).",
|
||||
"description": "A selection that names nothing, an exclusion list over 250000 or one resolving to more than 500 contacts (`too_many_contacts`), a select-all with no filters, a filter matching no contacts, or a filter matching more than 250000 (`selection_too_large`).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -31672,7 +31672,7 @@
|
||||
},
|
||||
"ContactSelection": {
|
||||
"type": "object",
|
||||
"description": "Names the contacts a bulk action applies to: either an explicit id list, or every contact matching a search (all + filters) minus the ids in exclude. The filter form lets one call cover far more contacts than a page, and is refused with selection_too_large past 50000 matches.",
|
||||
"description": "Names the contacts a bulk action applies to: either an explicit id list, or every contact matching a search (all + filters) minus the ids in exclude. The filter form lets one call cover far more contacts than a page, and is refused with selection_too_large past 250000 matches.",
|
||||
"oneOf": [
|
||||
{
|
||||
"required": [
|
||||
@@ -31704,12 +31704,12 @@
|
||||
"contacts": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 1000,
|
||||
"maxItems": 10000,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"description": "Contact ids (1 to 1000). Required unless all is set."
|
||||
"description": "Contact ids (1 to 10000). Required unless all is set."
|
||||
},
|
||||
"all": {
|
||||
"type": "boolean",
|
||||
@@ -31729,7 +31729,7 @@
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"maxItems": 50000,
|
||||
"maxItems": 250000,
|
||||
"description": "Contact ids to drop from the resolved set. Ignored unless all is set."
|
||||
}
|
||||
}
|
||||
@@ -31768,12 +31768,12 @@
|
||||
"contacts": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 1000,
|
||||
"maxItems": 10000,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"description": "Contact IDs to edit (1 to 1000). Required unless all is set."
|
||||
"description": "Contact IDs to edit (1 to 10000). Required unless all is set."
|
||||
},
|
||||
"all": {
|
||||
"type": "boolean",
|
||||
@@ -31793,7 +31793,7 @@
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"maxItems": 50000,
|
||||
"maxItems": 250000,
|
||||
"description": "Contact ids to drop from the resolved set. Ignored unless all is set."
|
||||
},
|
||||
"add_campaigns": {
|
||||
@@ -37910,7 +37910,7 @@
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"maxItems": 50000,
|
||||
"maxItems": 250000,
|
||||
"description": "Contact ids to drop from the resolved set. Ignored unless all is set."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
const maxBulkOperationSize = 1000
|
||||
|
||||
func (h *Handler) AddContacts(c *gin.Context) {
|
||||
userIDStr := middleware.GetUserID(c)
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ func (h *Handler) resolveContactSelection(c *gin.Context, orgID uuid.UUID, sel m
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "no contacts provided"))
|
||||
return nil, false
|
||||
}
|
||||
if len(sel.Contacts) > maxBulkOperationSize {
|
||||
if len(sel.Contacts) > models.MaxContactBatchIDs {
|
||||
errx.Handle(c, errx.NewWithIdentifier(errx.BadRequest, "too_many_contacts",
|
||||
fmt.Sprintf("too many contacts, maximum is %d per batch", maxBulkOperationSize)))
|
||||
fmt.Sprintf("too many contacts, maximum is %d per batch", models.MaxContactBatchIDs)))
|
||||
return nil, false
|
||||
}
|
||||
return sel.Contacts, true
|
||||
|
||||
@@ -568,7 +568,7 @@ func (s *service) ListCategories(ctx context.Context, orgID uuid.UUID) ([]models
|
||||
// creator is nil: an automation has no human behind it.
|
||||
func (s *service) CreateCategory(ctx context.Context, orgID uuid.UUID, title, color string) (models.MiniCategory, error) {
|
||||
if s.categoryRepo == nil {
|
||||
return models.MiniCategory{}, errx.New(errx.BadRequest, "categories are not available")
|
||||
return models.MiniCategory{}, errx.New(errx.BadRequest, "labels are not available")
|
||||
}
|
||||
if strings.TrimSpace(color) == "" {
|
||||
color = "#64748b"
|
||||
|
||||
@@ -25,7 +25,7 @@ func (d Deps) registerContactTools(r *Registry) {
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "get_contact",
|
||||
Description: "Get one contact by id, including custom fields, categories, subscription state, and engagement summary.",
|
||||
Description: "Get one contact by id, including custom fields, labels (categories), subscription state, and engagement summary.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"contact_id": strProp("The contact's UUID."),
|
||||
}, "contact_id"),
|
||||
@@ -55,10 +55,10 @@ func (d Deps) registerContactTools(r *Registry) {
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "add_tag",
|
||||
Description: "Add a category (tag) to a contact. The category_id comes from a contact's categories in get_contact/search results.",
|
||||
Description: "Add a label to a contact. Labels are called categories in the API; the category_id comes from a contact's categories in get_contact/search results.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"contact_id": strProp("The contact's UUID."),
|
||||
"category_id": strProp("The category (tag) UUID to add."),
|
||||
"category_id": strProp("The label (category) UUID to add."),
|
||||
}, "contact_id", "category_id"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
@@ -68,10 +68,10 @@ func (d Deps) registerContactTools(r *Registry) {
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "remove_tag",
|
||||
Description: "Remove a category (tag) from a contact.",
|
||||
Description: "Remove a label (category) from a contact.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"contact_id": strProp("The contact's UUID."),
|
||||
"category_id": strProp("The category (tag) UUID to remove."),
|
||||
"category_id": strProp("The label (category) UUID to remove."),
|
||||
}, "contact_id", "category_id"),
|
||||
Risk: generation.RiskWrite,
|
||||
RequiredOrgPerm: models.PermManageContacts,
|
||||
@@ -114,8 +114,8 @@ func (d Deps) registerContactTools(r *Registry) {
|
||||
Description: "Apply the same change (add/remove tags, set subscription) to many contacts at once.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"contact_ids": arrProp("Contact UUIDs to edit (required).", strProp("Contact UUID.")),
|
||||
"add_categories": arrProp("Category (tag) UUIDs to add to each contact.", strProp("Category UUID.")),
|
||||
"remove_categories": arrProp("Category (tag) UUIDs to remove from each contact.", strProp("Category UUID.")),
|
||||
"add_categories": arrProp("Label (category) UUIDs to add to each contact.", strProp("Label UUID.")),
|
||||
"remove_categories": arrProp("Label (category) UUIDs to remove from each contact.", strProp("Label UUID.")),
|
||||
"subscribe": boolProp("Set subscription state on each contact."),
|
||||
}, "contact_ids"),
|
||||
Risk: generation.RiskWrite,
|
||||
|
||||
@@ -30,7 +30,7 @@ func (d Deps) registerInboxActionTools(r *Registry) {
|
||||
|
||||
r.Register(Tool{
|
||||
Name: "set_thread_labels",
|
||||
Description: "Replace a conversation thread's label (category) set. Pass the full desired set; an empty list clears labels.",
|
||||
Description: "Replace a conversation thread's label set (labels are called categories in the API). Pass the full desired set; an empty list clears labels.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"thread_id": strProp("The thread id."),
|
||||
"category_ids": arrProp("Category (label) UUIDs to apply.", strProp("Category UUID.")),
|
||||
|
||||
@@ -194,7 +194,7 @@ func fieldHeader(f string) string {
|
||||
case models.ContactExportFieldSubscribed:
|
||||
return "Subscribed"
|
||||
case models.ContactExportFieldCategories:
|
||||
return "Categories"
|
||||
return "Labels"
|
||||
case models.ContactExportFieldCampaigns:
|
||||
return "Campaigns"
|
||||
case models.ContactExportFieldCreatedAt:
|
||||
|
||||
@@ -286,7 +286,7 @@ var Tables = []Table{
|
||||
{
|
||||
Name: "categories", Group: models.OrgDataGroupContacts,
|
||||
Scope: scopeOrg,
|
||||
Note: "The whole category registry travels, including ones no contact or conversation carries yet.",
|
||||
Note: "The whole label registry travels, including ones no contact or conversation carries yet.",
|
||||
},
|
||||
{
|
||||
Name: "contacts", Group: models.OrgDataGroupContacts,
|
||||
|
||||
@@ -283,12 +283,13 @@ func (s *service) CountAudience(ctx context.Context, orgID uuid.UUID, segmentIDs
|
||||
return s.repo.CountAudience(ctx, orgID, ids, campaignID)
|
||||
}
|
||||
|
||||
func parseContactIDs(raw []string) ([]uuid.UUID, *errx.Error) {
|
||||
func parseContactIDs(raw []string, max int) ([]uuid.UUID, *errx.Error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, errx.New(errx.BadRequest, "no contacts provided")
|
||||
}
|
||||
if len(raw) > 1000 {
|
||||
return nil, errx.New(errx.BadRequest, "at most 1000 contacts per request")
|
||||
if len(raw) > max {
|
||||
return nil, errx.NewWithIdentifier(errx.BadRequest, "too_many_contacts",
|
||||
fmt.Sprintf("too many contacts, maximum is %d per request", max))
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
@@ -307,7 +308,9 @@ func (s *service) SetMembers(ctx context.Context, orgID, id uuid.UUID, in *model
|
||||
default:
|
||||
return 0, errx.New(errx.BadRequest, "mode must be include, exclude or auto")
|
||||
}
|
||||
ids, xerr := parseContactIDs(in.Contacts)
|
||||
// The handler already bounded the selection by its shape; this is the
|
||||
// ceiling for callers that pass ids straight in.
|
||||
ids, xerr := parseContactIDs(in.Contacts, models.MaxContactBulkSelection)
|
||||
if xerr != nil {
|
||||
return 0, xerr
|
||||
}
|
||||
@@ -326,7 +329,7 @@ func (s *service) SetMembers(ctx context.Context, orgID, id uuid.UUID, in *model
|
||||
}
|
||||
|
||||
func (s *service) MemberModes(ctx context.Context, orgID, id uuid.UUID, contactIDs []string) (map[uuid.UUID]models.SegmentMemberMode, *errx.Error) {
|
||||
ids, xerr := parseContactIDs(contactIDs)
|
||||
ids, xerr := parseContactIDs(contactIDs, models.MaxContactBatchIDs)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
|
||||
@@ -330,7 +330,10 @@ const (
|
||||
// MaxContactBulkSelection bounds how many contacts one "select all matching"
|
||||
// bulk action may resolve to. Past it the action is refused and the user
|
||||
// narrows the filters, so a stray click can never walk a whole workspace.
|
||||
const MaxContactBulkSelection = 50000
|
||||
const MaxContactBulkSelection = 250000
|
||||
|
||||
// MaxContactBatchIDs bounds an explicit contact id list in one request body.
|
||||
const MaxContactBatchIDs = 10000
|
||||
|
||||
// ContactSelection names the contacts a bulk action applies to. Either an
|
||||
// explicit id list (Contacts), or every contact matching a search (All +
|
||||
|
||||
@@ -148,7 +148,7 @@ var SegmentFieldCatalog = []SegmentFieldSpec{
|
||||
{Field: "esp_provider", Label: "Email provider family", Group: "Contact", Kind: SegmentFieldEnum, Options: []string{"gmail", "outlook", "other"}, OptionLabels: map[string]string{"gmail": "Google", "outlook": "Microsoft", "other": "Other"}},
|
||||
{Field: "created_at", Label: "Created", Group: "Contact", Kind: SegmentFieldDate},
|
||||
{Field: "updated_at", Label: "Updated", Group: "Contact", Kind: SegmentFieldDate},
|
||||
{Field: "category", Label: "Category", Group: "Contact", Kind: SegmentFieldCategory},
|
||||
{Field: "category", Label: "Label", Group: "Contact", Kind: SegmentFieldCategory},
|
||||
|
||||
{Field: "company", Label: "Company name", Group: "Company", Kind: SegmentFieldText},
|
||||
|
||||
|
||||
@@ -3179,7 +3179,7 @@ func importCategoryNames(names []string) ([]string, map[string]string, *errx.Err
|
||||
}
|
||||
if len(title) > 50 {
|
||||
return nil, nil, errx.New(errx.BadRequest,
|
||||
"category name "+strconv.Quote(title)+" is longer than 50 characters")
|
||||
"label name "+strconv.Quote(title)+" is longer than 50 characters")
|
||||
}
|
||||
lower := strings.ToLower(title)
|
||||
if _, dup := seen[lower]; dup {
|
||||
|
||||
@@ -233,9 +233,9 @@ func stepLabel(name, kind string, action []byte, emailOrdinal int) string {
|
||||
}
|
||||
switch cfg.Type {
|
||||
case "add_tag":
|
||||
return "Add tag"
|
||||
return "Add label"
|
||||
case "remove_tag":
|
||||
return "Remove tag"
|
||||
return "Remove label"
|
||||
case "add_to_segment":
|
||||
return "Add to segment"
|
||||
case "remove_from_segment":
|
||||
|
||||
@@ -238,7 +238,7 @@ func setFormCategories(ctx context.Context, tx pgx.Tx, orgID, formID uuid.UUID,
|
||||
return nil
|
||||
}
|
||||
if len(categoryIDs) > models.FormMaxCategories {
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("at most %d categories per form", models.FormMaxCategories))
|
||||
return errx.New(errx.BadRequest, fmt.Sprintf("at most %d labels per form", models.FormMaxCategories))
|
||||
}
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO form_categories (form_id, category_id)
|
||||
|
||||
+13
-13
@@ -1,4 +1,4 @@
|
||||
// Categories tab: the workspace's contact labels with a live contact count,
|
||||
// Labels tab: the workspace's contact labels with a live contact count,
|
||||
// inline rename, color, create and delete. Clicking a row opens the contact
|
||||
// list filtered to that category.
|
||||
|
||||
@@ -31,7 +31,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const COLORS = ["#0284c7", "#7c3aed", "#db2777", "#dc2626", "#ea580c", "#ca8a04", "#16a34a", "#0d9488", "#475569"];
|
||||
|
||||
export default function CategoriesPage() {
|
||||
export default function LabelsPage() {
|
||||
const { user } = useUserProfile();
|
||||
const write = useWriteGuard("MANAGE_CONTACTS");
|
||||
const guarded = (fn: () => void) => () => write.guard(fn)({});
|
||||
@@ -76,14 +76,14 @@ export default function CategoriesPage() {
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageTopbar eyebrow="Categories" subtitle="Labels you put on contacts by hand, on import or from a sequence">
|
||||
<PageTopbar eyebrow="Labels" subtitle="One list for contacts, inbox conversations and forms">
|
||||
<TopbarAction icon={<PlusIcon className="w-3 h-3" />} onClick={guarded(() => setCreating(true))}>
|
||||
New category
|
||||
New label
|
||||
</TopbarAction>
|
||||
</PageTopbar>
|
||||
|
||||
<SectionBar label="All categories" count={list.length}>
|
||||
<SearchInput value={query} onChange={setQuery} placeholder="Search categories…" className="w-full sm:w-64" />
|
||||
<SectionBar label="All labels" count={list.length}>
|
||||
<SearchInput value={query} onChange={setQuery} placeholder="Search labels…" className="w-full sm:w-64" />
|
||||
</SectionBar>
|
||||
|
||||
<PageBody>
|
||||
@@ -95,7 +95,7 @@ export default function CategoriesPage() {
|
||||
}}
|
||||
className="h-11 px-5 flex items-center gap-2 border-b border-slate-200/60 bg-sky-50/40"
|
||||
>
|
||||
<TextInput value={newTitle} onChange={setNewTitle} placeholder="Category name" autoFocus className="w-64" />
|
||||
<TextInput value={newTitle} onChange={setNewTitle} placeholder="Label name" autoFocus className="w-64" />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newTitle.trim() || create.isPending}
|
||||
@@ -117,16 +117,16 @@ export default function CategoriesPage() {
|
||||
)}
|
||||
{list.length === 0 ? (
|
||||
<EmptyBlock
|
||||
title={query ? "No categories match" : "No categories yet"}
|
||||
title={query ? "No labels match" : "No labels yet"}
|
||||
body={
|
||||
query
|
||||
? "Try a different search."
|
||||
: "Categories are labels on a contact. Create one here, on a contact, or by mapping a column during import."
|
||||
: "Put a label on a contact or an inbox conversation. Create one here, on a contact, in the inbox, or by mapping a column during import."
|
||||
}
|
||||
cta={
|
||||
query ? undefined : (
|
||||
<TopbarAction icon={<PlusIcon className="w-3 h-3" />} onClick={guarded(() => setCreating(true))}>
|
||||
New category
|
||||
New label
|
||||
</TopbarAction>
|
||||
)
|
||||
}
|
||||
@@ -164,7 +164,7 @@ function CategoryRow({ category, count }: { category: Category; count?: number }
|
||||
}
|
||||
try {
|
||||
await update.mutateAsync({ title: next });
|
||||
toast.success("Category renamed");
|
||||
toast.success("Label renamed");
|
||||
setRenaming(false);
|
||||
} catch (err) {
|
||||
toast.error(buildError(err as AppError));
|
||||
@@ -181,10 +181,10 @@ function CategoryRow({ category, count }: { category: Category; count?: number }
|
||||
}
|
||||
|
||||
function askDelete() {
|
||||
confirm.show(`Delete the category "${category.title}"? It is removed from every contact and inbox thread; the contacts themselves are kept.`, async () => {
|
||||
confirm.show(`Delete the label "${category.title}"? It is removed from every contact and inbox conversation; the contacts themselves are kept.`, async () => {
|
||||
try {
|
||||
await remove.mutateAsync();
|
||||
toast.success("Category deleted");
|
||||
toast.success("Label deleted");
|
||||
} catch (err) {
|
||||
toast.error(buildError(err as AppError));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Contacts area: one tab strip over the contact list, saved segments and
|
||||
// categories, since all three are views of the same contact database.
|
||||
// labels, since all three are views of the same contact database.
|
||||
|
||||
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
@@ -11,7 +11,7 @@ import { usePermission } from "@/hooks/usePermission";
|
||||
const TABS = [
|
||||
{ label: "All contacts", path: "", Icon: UsersIcon },
|
||||
{ label: "Segments", path: "/segments", Icon: LayersIcon },
|
||||
{ label: "Categories", path: "/categories", Icon: TagIcon },
|
||||
{ label: "Labels", path: "/labels", Icon: TagIcon },
|
||||
{ label: "Suppression list", path: "/suppressions", Icon: BanIcon },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ function SegmentsList() {
|
||||
body={
|
||||
query
|
||||
? "Try a different search."
|
||||
: "Build an audience from contact fields, categories, campaign activity and engagement, then add it to a campaign in one step."
|
||||
: "Build an audience from contact fields, labels, campaign activity and engagement, then add it to a campaign in one step."
|
||||
}
|
||||
cta={
|
||||
query ? undefined : (
|
||||
|
||||
@@ -282,10 +282,10 @@ function FormsList() {
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
options={[
|
||||
{ value: "", label: "All categories" },
|
||||
{ value: "", label: "All labels" },
|
||||
...categories.map((c) => ({ value: c.id, label: c.title })),
|
||||
]}
|
||||
aria-label="Filter by category"
|
||||
aria-label="Filter by label"
|
||||
/>
|
||||
)}
|
||||
<SearchInput value={query} onChange={setQuery} placeholder="Search forms…" className="w-full sm:w-56" />
|
||||
@@ -310,7 +310,7 @@ function FormsList() {
|
||||
body={
|
||||
filtered
|
||||
? "Try a different search or filter."
|
||||
: "Build a form, style it to match your site, and every submission becomes a contact — filed under your categories and optionally dropped straight into a campaign."
|
||||
: "Build a form, style it to match your site, and every submission becomes a contact — filed under your labels and optionally dropped straight into a campaign."
|
||||
}
|
||||
cta={
|
||||
filtered ? undefined : (
|
||||
@@ -333,7 +333,7 @@ function FormsList() {
|
||||
/>
|
||||
</th>
|
||||
<SortTh label="Name" k="name" sort={sort} onSort={sortBy} className="max-w-0 w-full md:max-w-none md:w-auto" />
|
||||
<Th className="w-40 hidden lg:table-cell">Categories</Th>
|
||||
<Th className="w-40 hidden lg:table-cell">Labels</Th>
|
||||
<Th className="w-24 hidden lg:table-cell">Trend</Th>
|
||||
<SortTh label="Views" k="views" sort={sort} onSort={sortBy} className="w-16 text-right" right />
|
||||
<SortTh label="Starts" k="starts" sort={sort} onSort={sortBy} className="w-16 text-right hidden md:table-cell" right />
|
||||
|
||||
@@ -1724,8 +1724,8 @@ function toolLabel(tool: string): string {
|
||||
search_contacts: "Searched contacts",
|
||||
get_contact: "Read contact",
|
||||
update_contact_fields: "Update contact",
|
||||
add_tag: "Add tag",
|
||||
remove_tag: "Remove tag",
|
||||
add_tag: "Add label",
|
||||
remove_tag: "Remove label",
|
||||
list_campaigns: "Listed campaigns",
|
||||
get_campaign_stats: "Campaign stats",
|
||||
create_campaign_draft: "Create campaign draft",
|
||||
|
||||
@@ -1078,7 +1078,7 @@ export default function AutomationFlow({
|
||||
if (isNativeAction(d.action)) {
|
||||
const need = nativeActionNeeds(d.action);
|
||||
if (need === "tag" && !String(d.config?.category_id ?? "").trim()) {
|
||||
toast.error("A tag action needs a tag");
|
||||
toast.error("A label action needs a label");
|
||||
setSelectedId(n.id);
|
||||
return false;
|
||||
}
|
||||
@@ -1088,7 +1088,7 @@ export default function AutomationFlow({
|
||||
return false;
|
||||
}
|
||||
if (need === "label" && !triggerCarriesThread(trigger)) {
|
||||
toast.error("Label email only runs on a “Reply received” automation");
|
||||
toast.error("Label the conversation only runs on a “Reply received” automation");
|
||||
setSelectedId(n.id);
|
||||
return false;
|
||||
}
|
||||
@@ -2286,8 +2286,8 @@ function ConditionEditor({
|
||||
// the editor header. Drives both the action dropdown glyphs and the editor
|
||||
// header, so the picker reads like the campaign step picker.
|
||||
const ACTION_VISUAL: Record<string, { Icon: typeof TagIcon; tint: string; bg: string; desc?: string }> = {
|
||||
"warmbly.add_tag": { Icon: TagIcon, tint: "text-emerald-600", bg: "bg-emerald-50", desc: "Add a tag to the contact." },
|
||||
"warmbly.remove_tag": { Icon: TagIcon, tint: "text-amber-600", bg: "bg-amber-50", desc: "Remove a tag from the contact." },
|
||||
"warmbly.add_tag": { Icon: TagIcon, tint: "text-emerald-600", bg: "bg-emerald-50", desc: "Add a label to the contact." },
|
||||
"warmbly.remove_tag": { Icon: TagIcon, tint: "text-amber-600", bg: "bg-amber-50", desc: "Remove a label from the contact." },
|
||||
"warmbly.create_task": { Icon: CheckSquareIcon, tint: "text-violet-600", bg: "bg-violet-50", desc: "Open a CRM task for the contact." },
|
||||
"warmbly.create_deal": { Icon: BriefcaseIcon, tint: "text-sky-600", bg: "bg-sky-50", desc: "Create a CRM deal for the contact." },
|
||||
"warmbly.move_deal_stage": { Icon: BriefcaseIcon, tint: "text-sky-600", bg: "bg-sky-50", desc: "Move the contact's open deal to another stage." },
|
||||
@@ -2628,11 +2628,11 @@ function NativeActionConfig({
|
||||
<div className="space-y-3">
|
||||
{need === "tag" && (
|
||||
<div>
|
||||
<Label>{action === "warmbly.add_tag" ? "Tag to add" : "Tag to remove"}</Label>
|
||||
<Label>{action === "warmbly.add_tag" ? "Label to add" : "Label to remove"}</Label>
|
||||
<CategoryPicker
|
||||
value={config.category_id ? [String(config.category_id)] : []}
|
||||
onChange={(ids) => patchConfig({ category_id: ids.length ? ids[ids.length - 1] : "" })}
|
||||
placeholder="Pick a tag…"
|
||||
placeholder="Pick a label…"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2813,7 +2813,7 @@ function NativeActionConfig({
|
||||
type SetVarRow = { key: string; value: string };
|
||||
|
||||
const IF_EXISTS_OPTIONS: SelectOption[] = [
|
||||
{ value: "update", label: "Update it (fill blanks, add tags and campaign)" },
|
||||
{ value: "update", label: "Update it (fill blanks, add labels and campaign)" },
|
||||
{ value: "skip", label: "Leave it alone" },
|
||||
];
|
||||
|
||||
@@ -2915,11 +2915,11 @@ function UpsertContactFields({
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Tags</Label>
|
||||
<Label>Labels</Label>
|
||||
<CategoryPicker
|
||||
value={Array.isArray(config.category_ids) ? (config.category_ids as string[]) : []}
|
||||
onChange={(ids) => patchConfig({ category_ids: ids })}
|
||||
placeholder="Pick tags…"
|
||||
placeholder="Pick labels…"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -2942,7 +2942,7 @@ function UpsertContactFields({
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
A blank value never erases what the contact already has. The written contact becomes this event's contact, so the
|
||||
steps after it (tag, task, deal) act on it. A campaign picked here keeps running for new leads instead of finishing between runs.
|
||||
steps after it (label, task, deal) act on it. A campaign picked here keeps running for new leads instead of finishing between runs.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -3432,7 +3432,7 @@ function AITagPoolField({
|
||||
<p className="mt-1.5 text-[11px] text-slate-400">
|
||||
{value.length
|
||||
? "The agent chooses among these for each event."
|
||||
: "Empty, so the agent may use any of your tags for each event."}
|
||||
: "Empty, so the agent may use any of your labels for each event."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -3465,7 +3465,7 @@ function AIAgentFields({
|
||||
<AIInstruction
|
||||
value={String(config.instruction ?? "")}
|
||||
onChange={(v) => patchConfig({ instruction: v })}
|
||||
placeholder="Read the reply. If they ask about pricing, tag them 'pricing' and create a follow-up task."
|
||||
placeholder="Read the reply. If they ask about pricing, label them 'pricing' and create a follow-up task."
|
||||
/>
|
||||
<div>
|
||||
<Label>Actions the agent may take</Label>
|
||||
@@ -3495,10 +3495,10 @@ function AIAgentFields({
|
||||
<AITagPoolField
|
||||
label={
|
||||
id === "warmbly.add_tag"
|
||||
? "Tags the agent can add"
|
||||
? "Labels the agent can add"
|
||||
: id === "warmbly.remove_tag"
|
||||
? "Tags the agent can remove"
|
||||
: "Labels the agent can apply"
|
||||
? "Labels the agent can remove"
|
||||
: "Conversation labels the agent can apply"
|
||||
}
|
||||
value={poolFor(id)}
|
||||
onChange={(refs) => patchConfig({ [poolKey]: refs })}
|
||||
@@ -3523,7 +3523,7 @@ function AIAgentFields({
|
||||
>
|
||||
{!!config.ai_allow_create_tags && <CheckIcon className="w-3 h-3" />}
|
||||
</span>
|
||||
Let the agent create a new tag/label when none fits
|
||||
Let the agent create a new label when none fits
|
||||
</button>
|
||||
)}
|
||||
<p className="mt-1.5 text-[11px] text-slate-400 leading-relaxed">
|
||||
|
||||
@@ -549,11 +549,11 @@ function StopNode() {
|
||||
|
||||
// Per-type chrome for action nodes (icon + label + accent).
|
||||
const ACTION_META: Record<string, { label: string; Icon: typeof ClockIcon; tint: string }> = {
|
||||
add_tag: { label: "Add tag", Icon: TagIcon, tint: "text-emerald-600" },
|
||||
remove_tag: { label: "Remove tag", Icon: TagIcon, tint: "text-amber-600" },
|
||||
add_tag: { label: "Add label", Icon: TagIcon, tint: "text-emerald-600" },
|
||||
remove_tag: { label: "Remove label", Icon: TagIcon, tint: "text-amber-600" },
|
||||
add_to_segment: { label: "Add to segment", Icon: LayersIcon, tint: "text-emerald-600" },
|
||||
remove_from_segment: { label: "Remove from segment", Icon: LayersIcon, tint: "text-amber-600" },
|
||||
label_email: { label: "Label email", Icon: TagsIcon, tint: "text-fuchsia-600" },
|
||||
label_email: { label: "Label conversation", Icon: TagsIcon, tint: "text-fuchsia-600" },
|
||||
create_task: { label: "Create task", Icon: CheckSquareIcon, tint: "text-violet-600" },
|
||||
create_deal: { label: "Create deal", Icon: HandshakeIcon, tint: "text-emerald-600" },
|
||||
move_deal_stage: { label: "Move deal stage", Icon: ArrowRightLeftIcon, tint: "text-sky-600" },
|
||||
@@ -569,9 +569,9 @@ function actionSummary(a?: SequenceAction | null): string {
|
||||
if (!a) return "Not configured";
|
||||
switch (a.type) {
|
||||
case "add_tag":
|
||||
return a.category_id ? "Add a tag" : "Pick a tag…";
|
||||
return a.category_id ? "Add a label" : "Pick a label…";
|
||||
case "remove_tag":
|
||||
return a.category_id ? "Remove a tag" : "Pick a tag…";
|
||||
return a.category_id ? "Remove a label" : "Pick a label…";
|
||||
case "add_to_segment":
|
||||
return a.segment_id ? "Pin into a segment" : "Pick a segment…";
|
||||
case "remove_from_segment":
|
||||
@@ -2612,11 +2612,11 @@ function ConnectionEditor({
|
||||
// bottom dot unconnected (shows "Ends here") or routing a branch to Stop. That
|
||||
// keeps the cleaner Stop/"Ends here" visual instead of a configurable end node.
|
||||
const ADD_ACTION_OPTIONS: { type: SequenceActionType; label: string }[] = [
|
||||
{ type: "add_tag", label: "Add tag" },
|
||||
{ type: "remove_tag", label: "Remove tag" },
|
||||
{ type: "add_tag", label: "Add label" },
|
||||
{ type: "remove_tag", label: "Remove label" },
|
||||
{ type: "add_to_segment", label: "Add to segment" },
|
||||
{ type: "remove_from_segment", label: "Remove from segment" },
|
||||
{ type: "label_email", label: "Label email" },
|
||||
{ type: "label_email", label: "Label conversation" },
|
||||
{ type: "create_task", label: "Create task" },
|
||||
{ type: "create_deal", label: "Create deal" },
|
||||
{ type: "move_deal_stage", label: "Move deal stage" },
|
||||
@@ -3052,15 +3052,14 @@ function ActionConfigFields({
|
||||
<>
|
||||
{(action.type === "add_tag" || action.type === "remove_tag") && (
|
||||
<div>
|
||||
<Label>{action.type === "add_tag" ? "Tag to add" : "Tag to remove"}</Label>
|
||||
<Label>{action.type === "add_tag" ? "Label to add" : "Label to remove"}</Label>
|
||||
<CategoryPicker
|
||||
value={action.category_id ? [action.category_id] : []}
|
||||
onChange={(ids) =>
|
||||
setAction((a) => ({ ...a, category_id: ids.length ? ids[ids.length - 1] : null }))
|
||||
}
|
||||
placeholder="Pick a tag…"
|
||||
placeholder="Pick a label…"
|
||||
/>
|
||||
<p className="mt-1.5 text-[11px] text-slate-400">Tags are your contact categories.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3475,7 +3474,7 @@ function TagPoolField({
|
||||
<p className="mt-1.5 text-[11px] text-slate-400">
|
||||
{value.length
|
||||
? "The agent chooses among these for each contact."
|
||||
: "Empty, so the agent may use any of your tags for each contact."}
|
||||
: "Empty, so the agent may use any of your labels for each contact."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -3512,7 +3511,7 @@ function AIStepFields({
|
||||
value={action.ai_instruction ?? ""}
|
||||
onChange={(e) => setAction((a) => ({ ...a, ai_instruction: e.target.value }))}
|
||||
rows={3}
|
||||
placeholder="Read the reply. If they ask about pricing, tag them 'pricing' and create a follow-up task."
|
||||
placeholder="Read the reply. If they ask about pricing, label them 'pricing' and create a follow-up task."
|
||||
className="w-full resize-y rounded-md border border-slate-200 px-2.5 py-1.5 text-[12.5px] text-slate-700 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
@@ -3549,10 +3548,10 @@ function AIStepFields({
|
||||
<TagPoolField
|
||||
label={
|
||||
id === "add_tag"
|
||||
? "Tags the agent can add"
|
||||
? "Labels the agent can add"
|
||||
: id === "remove_tag"
|
||||
? "Tags the agent can remove"
|
||||
: "Labels the agent can apply"
|
||||
? "Labels the agent can remove"
|
||||
: "Conversation labels the agent can apply"
|
||||
}
|
||||
value={action[CAMPAIGN_AI_POOL_KEY[id]!] ?? []}
|
||||
onChange={(refs) =>
|
||||
@@ -3578,7 +3577,7 @@ function AIStepFields({
|
||||
>
|
||||
{action.ai_allow_create_tags && <CheckIcon className="w-3 h-3" />}
|
||||
</span>
|
||||
Let the agent create a new tag/label when none fits
|
||||
Let the agent create a new label when none fits
|
||||
</button>
|
||||
)}
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-slate-400">
|
||||
@@ -3779,7 +3778,7 @@ function SwitchStepFields({
|
||||
|
||||
<p className="rounded-md bg-slate-50 px-2.5 py-2 text-[11px] leading-relaxed text-slate-600 ring-1 ring-slate-200">
|
||||
Every case gets its own dot on the node — drag each dot to the step that path leads to, and the bottom
|
||||
dot is the “otherwise” fallback for contacts no case matched. Put normal action steps (tag, deal, task…)
|
||||
dot is the “otherwise” fallback for contacts no case matched. Put normal action steps (label, deal, task…)
|
||||
on a path to make things happen for the contacts routed down it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//
|
||||
// The Leads tab could import a file, sync a sheet, or type a new contact, but
|
||||
// had no way to pull in people already in the workspace. This dialog searches
|
||||
// the contact list (query + categories), shows who is already a lead, and
|
||||
// the contact list (query + labels), shows who is already a lead, and
|
||||
// attaches the selection through the bulk contact update (add_campaigns), the
|
||||
// same path the import wizard uses. "Select all matching" hands the server the
|
||||
// search itself, so a whole category is one request with no cap.
|
||||
// search itself, so a whole category is one request.
|
||||
|
||||
import React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
@@ -32,11 +32,11 @@ import type ContactSelection from "@/lib/api/models/app/contacts/ContactSelectio
|
||||
import * as rowSelection from "./selection";
|
||||
import type { RowSelection } from "./selection";
|
||||
|
||||
// Backend caps: 100 rows per search page, 1000 contacts per explicit batch.
|
||||
// Backend caps: 100 rows per search page, 10,000 contacts per explicit batch.
|
||||
// "Select all matching" sends the filter instead, so it is not bound by the
|
||||
// second one.
|
||||
const PAGE = 100;
|
||||
const MAX_SELECTION = 1000;
|
||||
const MAX_SELECTION = 10_000;
|
||||
|
||||
// The target is either a campaign (contacts become leads) or a segment
|
||||
// (contacts are pinned in as manual includes).
|
||||
@@ -234,7 +234,7 @@ export default function AddFromContactsDialog({ open, onClose, campaign: campaig
|
||||
<CategoryPicker
|
||||
value={categoryIds}
|
||||
onChange={setCategoryIds}
|
||||
placeholder="Filter by category…"
|
||||
placeholder="Filter by label…"
|
||||
allowCreate={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -290,7 +290,7 @@ export default function AddFromContactsDialog({ open, onClose, campaign: campaig
|
||||
</p>
|
||||
<p className="text-[11.5px] text-slate-400 mt-0.5">
|
||||
{debounced || categoryIds.length > 0
|
||||
? "Try a different search or category."
|
||||
? "Try a different search or label."
|
||||
: "Import a file or add contacts first."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ interface Props {
|
||||
export default function CategoryPicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Click to add categories…",
|
||||
placeholder = "Click to add labels…",
|
||||
className,
|
||||
allowCreate = true,
|
||||
}: Props) {
|
||||
@@ -101,7 +101,7 @@ export default function CategoryPicker({
|
||||
onChange([...value, c.id]);
|
||||
setQuery("");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "Failed to create category"));
|
||||
toast.error(errorMessage(err, "Failed to create label"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function CategoryPicker({
|
||||
<div className="max-h-56 overflow-y-auto py-1">
|
||||
{filtered.length === 0 && !allowCreate && (
|
||||
<div className="px-3 py-3 text-[11.5px] text-slate-400 text-center">
|
||||
No categories.
|
||||
No labels.
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((c) => {
|
||||
|
||||
@@ -232,7 +232,7 @@ export default function ContactsEditBulk({
|
||||
</PickerRow>
|
||||
</Section>
|
||||
|
||||
<Section title="Categories" subtitle="Labels put on the contacts themselves.">
|
||||
<Section title="Labels" subtitle="Labels put on the contacts themselves.">
|
||||
<PickerRow direction="add" label="Add">
|
||||
<CategoryPicker value={categoriesAdd} onChange={setCategoriesAdd} />
|
||||
</PickerRow>
|
||||
@@ -241,7 +241,7 @@ export default function ContactsEditBulk({
|
||||
value={categoriesRemove}
|
||||
onChange={setCategoriesRemove}
|
||||
allowCreate={false}
|
||||
placeholder="Pick categories to strip…"
|
||||
placeholder="Pick labels to strip…"
|
||||
/>
|
||||
</PickerRow>
|
||||
</Section>
|
||||
|
||||
@@ -125,6 +125,8 @@ type SubFilter = "all" | "subscribed" | "unsubscribed";
|
||||
// Mirrors maxIntegrationPushSize on the backend: one synchronous push is a
|
||||
// live call per contact against the CRM's API.
|
||||
const MAX_CRM_PUSH = 500;
|
||||
// Mirrors research.MaxBatch: every contact is a metered AI run.
|
||||
const MAX_RESEARCH_BATCH = 500;
|
||||
|
||||
export default function ContactsTable({
|
||||
current_campaign,
|
||||
@@ -499,6 +501,10 @@ export default function ContactsTable({
|
||||
const metered = useAiMetered();
|
||||
function bulkResearch() {
|
||||
if (selectionCount === 0) return;
|
||||
if (selectionCount > MAX_RESEARCH_BATCH) {
|
||||
toast.error(`Research takes up to ${MAX_RESEARCH_BATCH.toLocaleString()} contacts at a time. Narrow the selection and try again.`);
|
||||
return;
|
||||
}
|
||||
confirm?.show(
|
||||
`Research ${selectionCount.toLocaleString()} ${selectionCount === 1 ? "contact" : "contacts"}? ${
|
||||
metered
|
||||
|
||||
@@ -71,7 +71,7 @@ const STANDARD_FIELDS: { id: string; label: string; preset: "basic" | "full" | "
|
||||
{ id: "company", label: "Company", preset: "basic" },
|
||||
{ id: "phone", label: "Phone", preset: "basic" },
|
||||
{ id: "subscribed", label: "Subscribed", preset: "basic" },
|
||||
{ id: "categories", label: "Categories", preset: "full" },
|
||||
{ id: "categories", label: "Labels", preset: "full" },
|
||||
{ id: "campaigns", label: "Campaigns", preset: "full" },
|
||||
{ id: "created_at", label: "Created at", preset: "full" },
|
||||
{ id: "updated_at", label: "Updated at", preset: "full" },
|
||||
@@ -89,7 +89,7 @@ const CAMPAIGN_FIELDS: { id: string; label: string }[] = [
|
||||
|
||||
const PRESETS: { id: "basic" | "full" | "campaign-ready" | "custom"; label: string; hint: string }[] = [
|
||||
{ id: "basic", label: "Basic", hint: "Core contact details — what most CRMs expect." },
|
||||
{ id: "full", label: "Full", hint: "Every standard column including categories + campaigns." },
|
||||
{ id: "full", label: "Full", hint: "Every standard column including labels + campaigns." },
|
||||
{ id: "campaign-ready", label: "Campaign-ready", hint: "Email, names and company, plus lead status and engagement inside a campaign." },
|
||||
{ id: "custom", label: "Custom", hint: "Pick exactly what you need." },
|
||||
];
|
||||
|
||||
@@ -177,7 +177,7 @@ export function NewContactDialog({ open, onClose, campaign, segment }: Props) {
|
||||
<TextInput value={phone} onChange={setPhone} className="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Categories</Label>
|
||||
<Label>Labels</Label>
|
||||
<CategoryPicker value={categories} onChange={setCategories} />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -689,10 +689,10 @@ function OptionsStep({
|
||||
|
||||
<section>
|
||||
<h2 className="text-[10px] uppercase tracking-[0.14em] font-semibold text-slate-500 mb-2">
|
||||
Apply categories
|
||||
Apply labels
|
||||
</h2>
|
||||
<p className="text-[11px] text-slate-400 leading-tight mb-2">
|
||||
Every synced contact gets these categories. Skip to leave them untagged.
|
||||
Every synced contact gets these labels. Skip to leave them unlabeled.
|
||||
</p>
|
||||
<CategoryPicker value={categoryIds} onChange={setCategoryIds} />
|
||||
</section>
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function SyncSourceEditDrawer({
|
||||
|
||||
<section>
|
||||
<h2 className="text-[10px] uppercase tracking-[0.14em] font-semibold text-slate-500 mb-2">
|
||||
Apply categories
|
||||
Apply labels
|
||||
</h2>
|
||||
<CategoryPicker value={categoryIds} onChange={setCategoryIds} />
|
||||
</section>
|
||||
|
||||
@@ -1162,7 +1162,7 @@ function detailsFor(e: ContactTimelineEvent): [string, React.ReactNode][] {
|
||||
: e.email_account_email,
|
||||
);
|
||||
}
|
||||
add("Category", e.category_title);
|
||||
add("Label", e.category_title);
|
||||
add("Intent", e.intent);
|
||||
if (e.type === "deliverability" || e.type === "suppressed") {
|
||||
add("Type", e.source);
|
||||
@@ -1608,9 +1608,9 @@ function visualFor(e: ContactTimelineEvent): {
|
||||
case "campaign_removed":
|
||||
return { Icon: MegaphoneIcon, label: "Removed from campaign" };
|
||||
case "category_added":
|
||||
return { Icon: TagIcon, label: "Added to category" };
|
||||
return { Icon: TagIcon, label: "Label added" };
|
||||
case "category_removed":
|
||||
return { Icon: TagIcon, label: "Removed from category" };
|
||||
return { Icon: TagIcon, label: "Label removed" };
|
||||
case "form_submitted":
|
||||
return { Icon: ClipboardListIcon, label: "Submitted a form" };
|
||||
case "page_hit":
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function DetailsTab({
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Categories"
|
||||
title="Labels"
|
||||
accessory={
|
||||
<span className="text-[10.5px] text-slate-400 tabular-nums">
|
||||
{categoryIds.length}
|
||||
|
||||
@@ -211,7 +211,7 @@ export default function OverviewTab({
|
||||
/>
|
||||
<ProfileRow label="Phone" value={contact.phone || "—"} />
|
||||
<ProfileRow
|
||||
label="Categories"
|
||||
label="Labels"
|
||||
value={
|
||||
contact.categories.length > 0 ? (
|
||||
<span className="flex flex-wrap gap-1 justify-end">
|
||||
|
||||
@@ -221,14 +221,14 @@ export default function FilterBar({
|
||||
<div className="px-5 py-1.5 border-b border-slate-200/60 bg-white flex flex-wrap items-center gap-1.5">
|
||||
<MultiPill
|
||||
id="categories"
|
||||
label="Category"
|
||||
label="Label"
|
||||
openKey={openKey}
|
||||
setOpenKey={setOpenKey}
|
||||
value={filters.category_ids ?? []}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, category_ids: v.length ? v : undefined }))}
|
||||
options={categoryOptions}
|
||||
empty="No categories yet."
|
||||
hint="Contacts must have every selected category."
|
||||
empty="No labels yet."
|
||||
hint="Contacts must have every selected label."
|
||||
/>
|
||||
{!hideSegments && (
|
||||
<MultiPill
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function ReviewStep({
|
||||
</p>
|
||||
<p className="text-[11.5px] text-slate-500 mt-0.5">
|
||||
{dedup === "skip"
|
||||
? "Their details stay as they are. They still get the segments, categories and campaigns below."
|
||||
? "Their details stay as they are. They still get the segments, labels and campaigns below."
|
||||
: "Empty details are filled in from the file. Nothing they already have is erased."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -131,7 +131,7 @@ export default function ReviewStep({
|
||||
placeholder={lockedSegment ? "Add another segment…" : "Pick or create a segment…"}
|
||||
/>
|
||||
</Row>
|
||||
<Row icon={TagsIcon} label="Categories" hint="Labels to filter by">
|
||||
<Row icon={TagsIcon} label="Labels" hint="To filter and segment by">
|
||||
<CategoryPicker value={categoryIds} onChange={setCategoryIds} />
|
||||
</Row>
|
||||
<Row icon={MegaphoneIcon} label="Campaigns" hint="Enrolled as leads; an active campaign starts emailing them">
|
||||
|
||||
@@ -20,7 +20,7 @@ export const STANDARD_TARGETS: { id: string; label: string }[] = [
|
||||
{ id: "company", label: "Company" },
|
||||
{ id: "phone", label: "Phone" },
|
||||
{ id: "subscribed", label: "Subscribed" },
|
||||
{ id: "categories", label: "Categories" },
|
||||
{ id: "categories", label: "Labels" },
|
||||
{ id: "verification_status", label: "Verification status" },
|
||||
];
|
||||
|
||||
@@ -44,7 +44,7 @@ export const DEDUP_OPTIONS: { id: ImportDedupStrategy; label: string; hint: stri
|
||||
{
|
||||
id: "skip",
|
||||
label: "Skip existing",
|
||||
hint: "Leave their details alone. They still join the campaigns, categories and segments you pick.",
|
||||
hint: "Leave their details alone. They still join the campaigns, labels and segments you pick.",
|
||||
},
|
||||
{
|
||||
id: "update",
|
||||
@@ -247,7 +247,7 @@ export function fillRate(preview: ImportPreview, idx: number): number | null {
|
||||
// from scratch.
|
||||
export function sampleCSV(): string {
|
||||
return [
|
||||
"Email,First name,Last name,Company,Phone,Categories,Job title",
|
||||
"Email,First name,Last name,Company,Phone,Labels,Job title",
|
||||
"dana@acme.com,Dana,Reyes,Acme,+1 555 0100,Prospects;Q3,Head of Growth",
|
||||
"sam@northwind.io,Sam,Okafor,Northwind,,Prospects,Founder",
|
||||
].join("\n") + "\n";
|
||||
|
||||
@@ -86,11 +86,11 @@ export default function SettingsPanel({
|
||||
description="Where a submitted contact lands in your workspace."
|
||||
>
|
||||
<div>
|
||||
<Label>Add to categories</Label>
|
||||
<Label>Add labels</Label>
|
||||
<CategoryPicker
|
||||
value={draft.category_ids}
|
||||
onChange={(next) => onChange({ category_ids: next })}
|
||||
placeholder="Pick categories, e.g. Website leads"
|
||||
placeholder="Pick labels, e.g. Website leads"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500 mt-1">Every submitted contact is filed under these.</p>
|
||||
</div>
|
||||
|
||||
@@ -490,7 +490,7 @@ function ValueInput({
|
||||
case "enum":
|
||||
return <EnumMultiPicker value={values} onChange={setValues} options={spec.options ?? []} labels={spec.option_labels} />;
|
||||
case "category":
|
||||
return <CategoryPicker value={values} onChange={setValues} placeholder="Pick categories…" allowCreate={false} />;
|
||||
return <CategoryPicker value={values} onChange={setValues} placeholder="Pick labels…" allowCreate={false} />;
|
||||
case "campaign":
|
||||
return <CampaignMultiPicker value={values} onChange={setValues} />;
|
||||
case "segment":
|
||||
|
||||
@@ -77,7 +77,7 @@ export function ThreadLabelMenu({ threadId, open, onOpenChange }: Props) {
|
||||
setQuery("");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
errorMessage(err, "Failed to create category"),
|
||||
errorMessage(err, "Failed to create label"),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -208,7 +208,7 @@ export function ThreadLabelMenu({ threadId, open, onOpenChange }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="px-2.5 h-7 border-t border-slate-100 flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span>Labels are shared with contact categories</span>
|
||||
<span>Labels are shared with contacts</span>
|
||||
<kbd className="h-4 px-1 rounded border border-slate-200 bg-slate-50 font-mono inline-flex items-center">
|
||||
c
|
||||
</kbd>
|
||||
|
||||
@@ -396,7 +396,7 @@ export default function ContactRecipientField({
|
||||
<span className="size-1.5 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
|
||||
<span className="text-[11.5px] text-slate-800 truncate">{c.title}</span>
|
||||
<span className="ml-auto text-[9.5px] text-slate-400 shrink-0">
|
||||
filter by category
|
||||
filter by label
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -502,7 +502,7 @@ export default function ContactRecipientField({
|
||||
{allCategories.length > 0 && (
|
||||
<FilterMenu
|
||||
icon={TagIcon}
|
||||
allLabel="All categories"
|
||||
allLabel="All labels"
|
||||
options={allCategories.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.title,
|
||||
|
||||
@@ -39,7 +39,7 @@ const labelMap: Record<string, string> = {
|
||||
unibox: "Inbox",
|
||||
contacts: "Contacts",
|
||||
segments: "Segments",
|
||||
categories: "Categories",
|
||||
labels: "Labels",
|
||||
campaigns: "Campaigns",
|
||||
analytics: "Analytics",
|
||||
crm: "CRM",
|
||||
|
||||
@@ -6,7 +6,7 @@ const labelMap: Record<string, string> = {
|
||||
emails: 'Accounts',
|
||||
contacts: 'Contacts',
|
||||
segments: 'Segments',
|
||||
categories: 'Categories',
|
||||
labels: 'Labels',
|
||||
campaigns: 'Campaigns',
|
||||
unibox: 'Inbox',
|
||||
analytics: 'Analytics',
|
||||
|
||||
@@ -45,7 +45,7 @@ const ROUTE_TITLES: Record<string, string> = {
|
||||
"/app/emails/domains": "Sending domains",
|
||||
"/app/contacts": "Contacts",
|
||||
"/app/contacts/segments": "Segments",
|
||||
"/app/contacts/categories": "Categories",
|
||||
"/app/contacts/labels": "Labels",
|
||||
"/app/contacts/suppressions": "Suppression list",
|
||||
"/app/campaigns": "Campaigns",
|
||||
"/app/analytics": "Analytics",
|
||||
|
||||
@@ -44,14 +44,14 @@ export const ACTION_LABELS: Record<string, string> = {
|
||||
"close.upsert_lead": "Create / update Close lead",
|
||||
"webhook.ping": "Send a webhook",
|
||||
// Native (Warmbly built-in) actions — no external connection needed.
|
||||
"warmbly.add_tag": "Add a tag",
|
||||
"warmbly.remove_tag": "Remove a tag",
|
||||
"warmbly.add_tag": "Add a label",
|
||||
"warmbly.remove_tag": "Remove a label",
|
||||
"warmbly.create_task": "Create a task",
|
||||
"warmbly.create_deal": "Create a deal",
|
||||
"warmbly.move_deal_stage": "Move the deal stage",
|
||||
"warmbly.unsubscribe": "Unsubscribe the contact",
|
||||
"warmbly.run_automation": "Run another automation",
|
||||
"warmbly.label_email": "Label the email",
|
||||
"warmbly.label_email": "Label the conversation",
|
||||
"warmbly.set_variables": "Set variables",
|
||||
"warmbly.fire_event": "Fire event",
|
||||
"warmbly.upsert_contact": "Create or update contact",
|
||||
|
||||
+3
-2
@@ -17,7 +17,7 @@ import FormsPage from './app/app/forms/page';
|
||||
import FormBuilderPage from './app/app/forms/[id]/page';
|
||||
import ContactsLayout from './app/app/contacts/layout';
|
||||
import SegmentsPage from './app/app/contacts/segments/page';
|
||||
import CategoriesPage from './app/app/contacts/categories/page';
|
||||
import LabelsPage from './app/app/contacts/labels/page';
|
||||
import SuppressionsPage from './app/app/contacts/suppressions/page';
|
||||
import SegmentPage from './app/app/contacts/segments/[id]/page';
|
||||
import CampaignsPage from './app/app/campaigns/page';
|
||||
@@ -275,7 +275,8 @@ const router = createBrowserRouter([
|
||||
{ path: ":id", element: <SegmentPage /> },
|
||||
],
|
||||
},
|
||||
{ path: "categories", element: <CategoriesPage /> },
|
||||
{ path: "labels", element: <LabelsPage /> },
|
||||
{ path: "categories", element: <Navigate to="/app/contacts/labels" replace /> },
|
||||
{ path: "suppressions", element: <SuppressionsPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user