diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 953baf5cd..77f3bbaeb 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -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. diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx index 618f76b6a..bbd098b5f 100644 --- a/docs/content/docs/api/error-codes.mdx +++ b/docs/content/docs/api/error-codes.mdx @@ -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 | diff --git a/docs/content/docs/api/mcp.mdx b/docs/content/docs/api/mcp.mdx index 9e05cd7d4..f7579cad7 100644 --- a/docs/content/docs/api/mcp.mdx +++ b/docs/content/docs/api/mcp.mdx @@ -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` | diff --git a/docs/content/docs/api/reference/account-org.mdx b/docs/content/docs/api/reference/account-org.mdx index 19b82bb9a..c62f2187b 100644 --- a/docs/content/docs/api/reference/account-org.mdx +++ b/docs/content/docs/api/reference/account-org.mdx @@ -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). diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx index f695f689a..56d40c857 100644 --- a/docs/content/docs/api/reference/contacts.mdx +++ b/docs/content/docs/api/reference/contacts.mdx @@ -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:` 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:` 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 `) 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": { "": "include" | "exclude" } }` for the contacts that carry an override. diff --git a/docs/content/docs/api/reference/integrations.mdx b/docs/content/docs/api/reference/integrations.mdx index f89f494e2..8da268263 100644 --- a/docs/content/docs/api/reference/integrations.mdx +++ b/docs/content/docs/api/reference/integrations.mdx @@ -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. | diff --git a/docs/content/docs/api/reference/unibox.mdx b/docs/content/docs/api/reference/unibox.mdx index 760ade95b..d35236a0f 100644 --- a/docs/content/docs/api/reference/unibox.mdx +++ b/docs/content/docs/api/reference/unibox.mdx @@ -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 { diff --git a/docs/content/docs/development/local-development.mdx b/docs/content/docs/development/local-development.mdx index 7f1acf978..ffd943cf5 100644 --- a/docs/content/docs/development/local-development.mdx +++ b/docs/content/docs/development/local-development.mdx @@ -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 diff --git a/docs/content/docs/development/sandbox.mdx b/docs/content/docs/development/sandbox.mdx index 0b21e66a6..7e32d2c0b 100644 --- a/docs/content/docs/development/sandbox.mdx +++ b/docs/content/docs/development/sandbox.mdx @@ -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 diff --git a/docs/content/docs/guides/ai-assistant.mdx b/docs/content/docs/guides/ai-assistant.mdx index 51cf5ad62..1c7ebc4d0 100644 --- a/docs/content/docs/guides/ai-assistant.mdx +++ b/docs/content/docs/guides/ai-assistant.mdx @@ -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 diff --git a/docs/content/docs/guides/ai-steps-in-automations.mdx b/docs/content/docs/guides/ai-steps-in-automations.mdx index abb6c419e..fc8d99059 100644 --- a/docs/content/docs/guides/ai-steps-in-automations.mdx +++ b/docs/content/docs/guides/ai-steps-in-automations.mdx @@ -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. diff --git a/docs/content/docs/guides/automations.mdx b/docs/content/docs/guides/automations.mdx index f3ab7902f..8dec66c86 100644 --- a/docs/content/docs/guides/automations.mdx +++ b/docs/content/docs/guides/automations.mdx @@ -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. `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. 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. diff --git a/docs/content/docs/guides/forms.mdx b/docs/content/docs/guides/forms.mdx index e5da556ac..5026cde43 100644 --- a/docs/content/docs/guides/forms.mdx +++ b/docs/content/docs/guides/forms.mdx @@ -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. diff --git a/docs/content/docs/guides/make.mdx b/docs/content/docs/guides/make.mdx index ea236cfc7..c7012d674 100644 --- a/docs/content/docs/guides/make.mdx +++ b/docs/content/docs/guides/make.mdx @@ -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. diff --git a/docs/content/docs/guides/n8n.mdx b/docs/content/docs/guides/n8n.mdx index 39356e5e4..3e9a933f1 100644 --- a/docs/content/docs/guides/n8n.mdx +++ b/docs/content/docs/guides/n8n.mdx @@ -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. diff --git a/docs/content/docs/guides/segments.mdx b/docs/content/docs/guides/segments.mdx index 667ffc6af..c76ad4d8e 100644 --- a/docs/content/docs/guides/segments.mdx +++ b/docs/content/docs/guides/segments.mdx @@ -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`. - -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. + +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. ## 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 diff --git a/docs/content/docs/guides/sequences.mdx b/docs/content/docs/guides/sequences.mdx index 3aad34283..ae297dbc6 100644 --- a/docs/content/docs/guides/sequences.mdx +++ b/docs/content/docs/guides/sequences.mdx @@ -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. -**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. ### Switch steps diff --git a/docs/content/docs/guides/team-roles.mdx b/docs/content/docs/guides/team-roles.mdx index 1d8a076d8..4ca73b9b3 100644 --- a/docs/content/docs/guides/team-roles.mdx +++ b/docs/content/docs/guides/team-roles.mdx @@ -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 | diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index ae6be335a..8ed6f9b7a 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -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. - -Categories label conversations. Tags label the mailboxes themselves (grouping accounts by client or domain). Both filter from the rail, but they describe different things. + +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. ## Read state diff --git a/docs/content/docs/guides/workspace-export-import.mdx b/docs/content/docs/guides/workspace-export-import.mdx index ee82bc6cb..05bd9228b 100644 --- a/docs/content/docs/guides/workspace-export-import.mdx +++ b/docs/content/docs/guides/workspace-export-import.mdx @@ -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 | diff --git a/docs/content/docs/guides/zapier.mdx b/docs/content/docs/guides/zapier.mdx index 6d8f3d25d..6fa308368 100644 --- a/docs/content/docs/guides/zapier.mdx +++ b/docs/content/docs/guides/zapier.mdx @@ -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. diff --git a/docs/public/openapi.json b/docs/public/openapi.json index e374d8f4a..9cbcdf5db 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -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." } }, diff --git a/internal/api/handler/contact.go b/internal/api/handler/contact.go index a7a9dd0e7..49c1d1f25 100644 --- a/internal/api/handler/contact.go +++ b/internal/api/handler/contact.go @@ -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) diff --git a/internal/api/handler/contact_selection.go b/internal/api/handler/contact_selection.go index 2922529c6..ba06b4375 100644 --- a/internal/api/handler/contact_selection.go +++ b/internal/api/handler/contact_selection.go @@ -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 diff --git a/internal/app/advanced/service.go b/internal/app/advanced/service.go index d56c599eb..10f0e8228 100644 --- a/internal/app/advanced/service.go +++ b/internal/app/advanced/service.go @@ -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" diff --git a/internal/app/aitools/tools_contacts.go b/internal/app/aitools/tools_contacts.go index 4dbce9e1d..93d632e54 100644 --- a/internal/app/aitools/tools_contacts.go +++ b/internal/app/aitools/tools_contacts.go @@ -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, diff --git a/internal/app/aitools/tools_inbox.go b/internal/app/aitools/tools_inbox.go index 4b12d94fd..00c3f6e7c 100644 --- a/internal/app/aitools/tools_inbox.go +++ b/internal/app/aitools/tools_inbox.go @@ -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.")), diff --git a/internal/app/contact/export.go b/internal/app/contact/export.go index 9de2b1190..158980a58 100644 --- a/internal/app/contact/export.go +++ b/internal/app/contact/export.go @@ -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: diff --git a/internal/app/orgtransfer/spec.go b/internal/app/orgtransfer/spec.go index e06c45aec..a490848bf 100644 --- a/internal/app/orgtransfer/spec.go +++ b/internal/app/orgtransfer/spec.go @@ -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, diff --git a/internal/app/segment/service.go b/internal/app/segment/service.go index 9c665860e..a64e28911 100644 --- a/internal/app/segment/service.go +++ b/internal/app/segment/service.go @@ -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 } diff --git a/internal/models/contact.go b/internal/models/contact.go index d5f0be571..a96530f48 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -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 + diff --git a/internal/models/segment.go b/internal/models/segment.go index 99173cf10..e8078a179 100644 --- a/internal/models/segment.go +++ b/internal/models/segment.go @@ -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}, diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go index 6a6bd57db..a18cfa26f 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -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 { diff --git a/internal/repository/pg_contact_campaign_state.go b/internal/repository/pg_contact_campaign_state.go index 1dfe0d91b..112dbd941 100644 --- a/internal/repository/pg_contact_campaign_state.go +++ b/internal/repository/pg_contact_campaign_state.go @@ -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": diff --git a/internal/repository/pg_form.go b/internal/repository/pg_form.go index 79a0cacb4..838054dda 100644 --- a/internal/repository/pg_form.go +++ b/internal/repository/pg_form.go @@ -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) diff --git a/web/src/app/app/contacts/categories/page.tsx b/web/src/app/app/contacts/labels/page.tsx similarity index 91% rename from web/src/app/app/contacts/categories/page.tsx rename to web/src/app/app/contacts/labels/page.tsx index e86052092..516d6361d 100644 --- a/web/src/app/app/contacts/categories/page.tsx +++ b/web/src/app/app/contacts/labels/page.tsx @@ -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 ( - + } onClick={guarded(() => setCreating(true))}> - New category + New label - - + + @@ -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" > - +
- + patchConfig({ category_ids: ids })} - placeholder="Pick tags…" + placeholder="Pick labels…" />
@@ -2942,7 +2942,7 @@ function UpsertContactFields({

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.

); @@ -3432,7 +3432,7 @@ function AITagPoolField({

{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."}

); @@ -3465,7 +3465,7 @@ function AIAgentFields({ 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." />
@@ -3495,10 +3495,10 @@ function AIAgentFields({ patchConfig({ [poolKey]: refs })} @@ -3523,7 +3523,7 @@ function AIAgentFields({ > {!!config.ai_allow_create_tags && } - Let the agent create a new tag/label when none fits + Let the agent create a new label when none fits )}

diff --git a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx index 37500659f..a9d5252b4 100644 --- a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx +++ b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx @@ -549,11 +549,11 @@ function StopNode() { // Per-type chrome for action nodes (icon + label + accent). const ACTION_META: Record = { - 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") && (

- + setAction((a) => ({ ...a, category_id: ids.length ? ids[ids.length - 1] : null })) } - placeholder="Pick a tag…" + placeholder="Pick a label…" /> -

Tags are your contact categories.

)} @@ -3475,7 +3474,7 @@ function TagPoolField({

{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."}

); @@ -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" /> @@ -3549,10 +3548,10 @@ function AIStepFields({ @@ -3578,7 +3577,7 @@ function AIStepFields({ > {action.ai_allow_create_tags && } - Let the agent create a new tag/label when none fits + Let the agent create a new label when none fits )}

@@ -3779,7 +3778,7 @@ function SwitchStepFields({

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.

diff --git a/web/src/components/app/contacts/AddFromContactsDialog.tsx b/web/src/components/app/contacts/AddFromContactsDialog.tsx index 4864f5c01..be1b06392 100644 --- a/web/src/components/app/contacts/AddFromContactsDialog.tsx +++ b/web/src/components/app/contacts/AddFromContactsDialog.tsx @@ -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 @@ -290,7 +290,7 @@ export default function AddFromContactsDialog({ open, onClose, campaign: campaig

{debounced || categoryIds.length > 0 - ? "Try a different search or category." + ? "Try a different search or label." : "Import a file or add contacts first."}

diff --git a/web/src/components/app/contacts/CategoryPicker.tsx b/web/src/components/app/contacts/CategoryPicker.tsx index b88f95cc7..315587f36 100644 --- a/web/src/components/app/contacts/CategoryPicker.tsx +++ b/web/src/components/app/contacts/CategoryPicker.tsx @@ -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({
{filtered.length === 0 && !allowCreate && (
- No categories. + No labels.
)} {filtered.map((c) => { diff --git a/web/src/components/app/contacts/ContactsEditBulk.tsx b/web/src/components/app/contacts/ContactsEditBulk.tsx index 4d20313ab..7ec5aeaab 100644 --- a/web/src/components/app/contacts/ContactsEditBulk.tsx +++ b/web/src/components/app/contacts/ContactsEditBulk.tsx @@ -232,7 +232,7 @@ export default function ContactsEditBulk({ -
+
@@ -241,7 +241,7 @@ export default function ContactsEditBulk({ value={categoriesRemove} onChange={setCategoriesRemove} allowCreate={false} - placeholder="Pick categories to strip…" + placeholder="Pick labels to strip…" />
diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index c818270bb..425006d02 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -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 diff --git a/web/src/components/app/contacts/ExportDialog.tsx b/web/src/components/app/contacts/ExportDialog.tsx index 572a0a980..79a69353d 100644 --- a/web/src/components/app/contacts/ExportDialog.tsx +++ b/web/src/components/app/contacts/ExportDialog.tsx @@ -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." }, ]; diff --git a/web/src/components/app/contacts/NewContactDialog.tsx b/web/src/components/app/contacts/NewContactDialog.tsx index 5e3bc63ae..621c04595 100644 --- a/web/src/components/app/contacts/NewContactDialog.tsx +++ b/web/src/components/app/contacts/NewContactDialog.tsx @@ -177,7 +177,7 @@ export function NewContactDialog({ open, onClose, campaign, segment }: Props) {
- +
diff --git a/web/src/components/app/contacts/SheetSyncWizard.tsx b/web/src/components/app/contacts/SheetSyncWizard.tsx index c16901f53..309147d45 100644 --- a/web/src/components/app/contacts/SheetSyncWizard.tsx +++ b/web/src/components/app/contacts/SheetSyncWizard.tsx @@ -689,10 +689,10 @@ function OptionsStep({

- Apply categories + Apply labels

- Every synced contact gets these categories. Skip to leave them untagged. + Every synced contact gets these labels. Skip to leave them unlabeled.

diff --git a/web/src/components/app/contacts/SyncSourceEditDrawer.tsx b/web/src/components/app/contacts/SyncSourceEditDrawer.tsx index b48f00caa..5a6f77857 100644 --- a/web/src/components/app/contacts/SyncSourceEditDrawer.tsx +++ b/web/src/components/app/contacts/SyncSourceEditDrawer.tsx @@ -223,7 +223,7 @@ export default function SyncSourceEditDrawer({

- Apply categories + Apply labels

diff --git a/web/src/components/app/contacts/contact-edit/ActivityTab.tsx b/web/src/components/app/contacts/contact-edit/ActivityTab.tsx index 4d5bc8944..09b15ca96 100644 --- a/web/src/components/app/contacts/contact-edit/ActivityTab.tsx +++ b/web/src/components/app/contacts/contact-edit/ActivityTab.tsx @@ -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": diff --git a/web/src/components/app/contacts/contact-edit/DetailsTab.tsx b/web/src/components/app/contacts/contact-edit/DetailsTab.tsx index fa2622648..28cc3dadb 100644 --- a/web/src/components/app/contacts/contact-edit/DetailsTab.tsx +++ b/web/src/components/app/contacts/contact-edit/DetailsTab.tsx @@ -106,7 +106,7 @@ export default function DetailsTab({
{categoryIds.length} diff --git a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx index 94dc25930..6c8c003aa 100644 --- a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx +++ b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx @@ -211,7 +211,7 @@ export default function OverviewTab({ /> 0 ? ( diff --git a/web/src/components/app/contacts/filters/FilterBar.tsx b/web/src/components/app/contacts/filters/FilterBar.tsx index 427d326a5..c987e5fe1 100644 --- a/web/src/components/app/contacts/filters/FilterBar.tsx +++ b/web/src/components/app/contacts/filters/FilterBar.tsx @@ -221,14 +221,14 @@ export default function FilterBar({
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 && (

{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."}

@@ -131,7 +131,7 @@ export default function ReviewStep({ placeholder={lockedSegment ? "Add another segment…" : "Pick or create a segment…"} /> - + diff --git a/web/src/components/app/contacts/importShared.ts b/web/src/components/app/contacts/importShared.ts index 8c2992d0a..a397951ca 100644 --- a/web/src/components/app/contacts/importShared.ts +++ b/web/src/components/app/contacts/importShared.ts @@ -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"; diff --git a/web/src/components/app/forms/SettingsPanel.tsx b/web/src/components/app/forms/SettingsPanel.tsx index cc0a197d8..35b68e6a4 100644 --- a/web/src/components/app/forms/SettingsPanel.tsx +++ b/web/src/components/app/forms/SettingsPanel.tsx @@ -86,11 +86,11 @@ export default function SettingsPanel({ description="Where a submitted contact lands in your workspace." >
- + onChange({ category_ids: next })} - placeholder="Pick categories, e.g. Website leads" + placeholder="Pick labels, e.g. Website leads" />

Every submitted contact is filed under these.

diff --git a/web/src/components/app/segments/SegmentEditor.tsx b/web/src/components/app/segments/SegmentEditor.tsx index 6f2803417..027341026 100644 --- a/web/src/components/app/segments/SegmentEditor.tsx +++ b/web/src/components/app/segments/SegmentEditor.tsx @@ -490,7 +490,7 @@ function ValueInput({ case "enum": return ; case "category": - return ; + return ; case "campaign": return ; case "segment": diff --git a/web/src/components/app/unibox/ThreadLabelMenu.tsx b/web/src/components/app/unibox/ThreadLabelMenu.tsx index 8436dfebd..3e6696d93 100644 --- a/web/src/components/app/unibox/ThreadLabelMenu.tsx +++ b/web/src/components/app/unibox/ThreadLabelMenu.tsx @@ -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) {
- Labels are shared with contact categories + Labels are shared with contacts c diff --git a/web/src/components/app/unibox/compose/ContactRecipientField.tsx b/web/src/components/app/unibox/compose/ContactRecipientField.tsx index 19acd303e..6a06ea485 100644 --- a/web/src/components/app/unibox/compose/ContactRecipientField.tsx +++ b/web/src/components/app/unibox/compose/ContactRecipientField.tsx @@ -396,7 +396,7 @@ export default function ContactRecipientField({ {c.title} - filter by category + filter by label ))} @@ -502,7 +502,7 @@ export default function ContactRecipientField({ {allCategories.length > 0 && ( ({ id: c.id, label: c.title, diff --git a/web/src/components/layout/AppHeader.tsx b/web/src/components/layout/AppHeader.tsx index 26d734227..aefda2937 100644 --- a/web/src/components/layout/AppHeader.tsx +++ b/web/src/components/layout/AppHeader.tsx @@ -39,7 +39,7 @@ const labelMap: Record = { unibox: "Inbox", contacts: "Contacts", segments: "Segments", - categories: "Categories", + labels: "Labels", campaigns: "Campaigns", analytics: "Analytics", crm: "CRM", diff --git a/web/src/components/layout/DynamicBreadcrumb.tsx b/web/src/components/layout/DynamicBreadcrumb.tsx index ead2f1540..6c2f078b2 100644 --- a/web/src/components/layout/DynamicBreadcrumb.tsx +++ b/web/src/components/layout/DynamicBreadcrumb.tsx @@ -6,7 +6,7 @@ const labelMap: Record = { emails: 'Accounts', contacts: 'Contacts', segments: 'Segments', - categories: 'Categories', + labels: 'Labels', campaigns: 'Campaigns', unibox: 'Inbox', analytics: 'Analytics', diff --git a/web/src/hooks/useDocumentTitle.ts b/web/src/hooks/useDocumentTitle.ts index 068829ecb..7610bf48f 100644 --- a/web/src/hooks/useDocumentTitle.ts +++ b/web/src/hooks/useDocumentTitle.ts @@ -45,7 +45,7 @@ const ROUTE_TITLES: Record = { "/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", diff --git a/web/src/lib/api/models/app/automations/meta.ts b/web/src/lib/api/models/app/automations/meta.ts index 9db09329a..5f8676915 100644 --- a/web/src/lib/api/models/app/automations/meta.ts +++ b/web/src/lib/api/models/app/automations/meta.ts @@ -44,14 +44,14 @@ export const ACTION_LABELS: Record = { "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", diff --git a/web/src/main.tsx b/web/src/main.tsx index 24ad3f831..71e142519 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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: }, ], }, - { path: "categories", element: }, + { path: "labels", element: }, + { path: "categories", element: }, { path: "suppressions", element: }, ], },