605 Commits
Author SHA1 Message Date
887897ea98 fix: lock agent quota checks (#15029)
# Pull Request Template

## Description

Locks the agent quota check to the account row while creating account
users. This fixes a race where concurrent agent-create requests could
all observe the same remaining seat before any `account_users` row was
inserted.

The API continues to return the existing `402 Account limit exceeded.
Please purchase more licenses` response when the limit is reached. Bulk
create now preflights the requested email count while holding the
account lock, then creates each agent through the same locked builder
path. The Enterprise custom-role hook now no-ops when create did not
produce an agent.

Fixes:
[CW-7039](https://linear.app/chatwoot/issue/CW-7039/race-condition-in-agent-creation-bypasses-plan-agent-seat-limit)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- `POSTGRES_DATABASE=chatwoot_test_c20f_agent_quota REDIS_DB=9 bundle
exec rspec spec/builders/agent_builder_spec.rb
spec/enterprise/builders/agent_builder_spec.rb
spec/controllers/api/v1/accounts/agents_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/agents_controller_spec.rb`
- `bundle exec rubocop app/builders/agent_builder.rb
app/controllers/api/v1/accounts/agents_controller.rb
enterprise/app/controllers/enterprise/api/v1/accounts/agents_controller.rb
spec/builders/agent_builder_spec.rb
spec/enterprise/controllers/api/v1/accounts/agents_controller_spec.rb`
- `git diff --check`
- One-off threaded Rails validation with 8 concurrent `AgentBuilder`
calls against an account with one remaining seat: `created: 1`,
`limited: 7`, final `count=2`, `limit=2`.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-22 18:43:00 +05:30
8aee518149 fix(integrations): restrict Linear/Notion/Shopify hook deletion to admins (#15126)
Non-admin agents could delete an account's Linear, Notion, or Shopify
integration through the dedicated integration endpoints, which — unlike
the generic hooks endpoint — never checked the caller's role. This
restores the intended admin-only boundary for removing an integration.

## Closes
- https://linear.app/chatwoot/issue/CW-7383
- https://linear.app/chatwoot/issue/CW-7384
- https://linear.app/chatwoot/issue/CW-7189

## How to reproduce
As a non-admin **agent**, `DELETE
/api/v1/accounts/:id/integrations/{linear,notion,shopify}` returned
`200` and removed the account-wide integration. After this change it
returns `401` and the integration is preserved; administrators can still
remove it.

## What changed
- Route integration-hook deletion through `HookPolicy` (admin-only) via
a shared `Integrations::BaseController`, matching the generic hooks
controller.

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-07-22 17:53:25 +05:30
7d2f01e402 feat(whatsapp): unify embedded signup feature gating (#15106)
WhatsApp embedded signup now uses
`whatsapp_embedded_signup_inbox_creation` as the single Chatwoot Cloud
rollout gate for inbox creation, proactive reconfiguration, and
disconnected inbox reauthorization. The authorization endpoint enforces
the same gate, so the UI and backend remain consistent.

Self-hosted installations keep their existing behavior.

## Things to know

- This reuses the existing feature flag; there is no migration or schema
change.
- The feature is shown as “WhatsApp Embedded Signup Flow” in feature
management.
- `whatsapp_reconfigure` remains visible and honored for self-hosted
proactive reconfiguration to preserve existing accounts. It can be
deprecated after the self-hosted dependency is removed or migrated.

## How to test

1. On Chatwoot Cloud, enable `whatsapp_embedded_signup_inbox_creation`
for an account.
2. Confirm that new WhatsApp inbox creation, proactive reconfiguration,
and disconnected inbox reauthorization are available.
3. Disable the flag and confirm those entry points are hidden and
authorization requests are rejected.
4. On self-hosted, confirm proactive reconfiguration remains controlled
by the existing `whatsapp_reconfigure` account setting.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-21 15:05:11 +04:00
Sojan JoseandGitHub eae9841eb4 fix: restore token access to account APIs (#15088)
Token-authenticated requests to Agent Bots, Labels, and affected Captain
endpoints return normal responses again. The regression was caused by
duplicate `current_account` callbacks in subclasses moving account
resolution behind the API entitlement check, leaving `Current.account`
unset.

## Closes

- https://linear.app/chatwoot/issue/CW-7641/5xx-errors-in-agent-bot-apis

## How to reproduce

1. Send `GET /api/v1/accounts/:account_id/agent_bots` with a valid
administrator API access token.
2. Observe a `500` from `validate_token_api_access` because
`Current.account` is `nil`.
3. With this change, account resolution runs in the base-controller
order and the request succeeds.

## What changed

- Removed redundant `current_account` callbacks from account-scoped
controllers that already inherit the callback from
`Api::V1::Accounts::BaseController`.
- Kept the standalone direct-upload controller callback unchanged.
- Added regression coverage for administrator API-token access to Agent
Bots.
2026-07-20 15:28:33 -07:00
Sony MathewandGitHub c420edce58 feat: branded email layouts for email inboxes (#14936)
## Description

Adds an API-only branded email layout feature for Email inbox replies.
Administrators can configure an account-level fallback layout and
per-email-inbox overrides with Liquid HTML using `{{ content_for_layout
}}`, and eligible outbound email replies/transcripts render through the
scoped layout when the account feature flag `branded_email_templates` is
enabled.

The feature is disabled by default and is manually controlled through
the normal account feature flag mechanism.

Fixes
https://linear.app/chatwoot/issue/CW-7514/branded-html-email-templates-per-inboxbrand

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

## How to test

1. Start Chatwoot locally and sign in as an administrator.
2. Enable the account feature flag for the account you are testing:

   ```ruby
   account = Account.find(<account_id>)
   account.enable_features!(:branded_email_templates)
   ```

3. Create or pick an Email inbox, then note the `account_id` and
`inbox_id`.
4. Configure an account-level fallback layout through the API using
authenticated admin headers:

   ```http
   PATCH /api/v1/accounts/:account_id/branded_email_layout
   Content-Type: application/json

   {
"branded_email_layout": "<html><body><header>Account Brand</header>{{
content_for_layout }}<footer>Account footer</footer></body></html>"
   }
   ```

5. Confirm `GET /api/v1/accounts/:account_id/branded_email_layout`
returns the saved account layout.
6. Configure an inbox-level override for the Email inbox:

   ```http
   PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id
   Content-Type: application/json

   {
"branded_email_layout": "<html><body><header>Inbox Brand</header>{{
content_for_layout }}<footer>Inbox footer</footer></body></html>"
   }
   ```

7. Confirm `GET /api/v1/accounts/:account_id/inboxes/:inbox_id` returns
the inbox `branded_email_layout`.
8. Send an Email inbox reply and verify the outbound email body is
wrapped with the inbox layout around the generated reply content.
9. Clear the inbox layout by sending a blank value, then send another
reply and verify it falls back to the account layout:

   ```http
   PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id
   Content-Type: application/json

   {
     "branded_email_layout": ""
   }
   ```

10. Clear the account layout with a blank value and verify Email replies
return to the existing no-layout behavior.
11. Verify validation behavior:
- Updating either API with a layout that omits `{{ content_for_layout
}}` returns `422`.
    - Updating either API with invalid Liquid returns `422`.
- Updating a non-Email inbox with `branded_email_layout` returns `422`.
- Disabling `branded_email_templates` and updating a layout returns
`422`.

## How Has This Been Tested?

Validation:

- `bundle exec rspec spec/models/email_template_spec.rb
spec/controllers/api/v1/accounts/branded_email_layouts_controller_spec.rb
spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
spec/lib/email_templates/db_resolver_service_spec.rb
spec/mailers/conversation_reply_mailer_spec.rb`
- `bundle exec rspec
spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb`
- `bundle exec rubocop` on changed Ruby files, excluding generated
`db/schema.rb`
- `git diff --check` and `git diff --cached --check`
- YAML parsing for changed config/Swagger files
- `bundle exec rails routes -g branded_email_layout`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have made corresponding changes to the documentation
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] My changes generate no new warnings
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-17 15:25:55 +05:30
Sivin VargheseandGitHub 331875cdaa feat: Add popular content per locale (#14939) 2026-07-17 14:43:42 +05:30
Shivam MishraandGitHub 8dd0d08322 refactor: align conversation direct uploads with standard account auth (#15039)
Conversation attachment uploads now go through the same authentication
that every other account-scoped API endpoint uses. Agents continue to
attach files exactly as before, and the upload request is now tied to
the agent's dashboard session instead of a separately serialized access
token.

Because the upload request is now authenticated, the dashboard proves
the agent's session directly instead of passing
`currentUser.access_token`. This keeps uploads working alongside the
profile access-token changes in #14973, including on accounts where that
token is serialized as empty.

## What changed

- `Api::V1::Accounts::Conversations::DirectUploadsController` now runs
the standard account auth stack: API access token when the
`api_access_token` header is present, dashboard session
(devise-token-auth) otherwise, with agent-bot tokens rejected.
Previously it inherited `ActiveStorage::DirectUploadsController`
directly and did not run any authentication.
- `EnsureCurrentAccountHelper#ensure_current_account` now returns `401`
when a request has neither an authenticated user nor a bot resource,
instead of continuing. This closes the same gap for any controller that
relies on the helper.
- The dashboard direct-upload paths (`useFileUpload.js` and the legacy
`fileUploadMixin.js`) now attach the agent's session headers to the
upload request via a new `directUploadsHelper.js`, instead of sending
`currentUser.access_token`.

## How to test

1. As a logged-in agent, open a conversation and attach a file. Upload
should succeed as before, on installs with direct uploads enabled.
2. Confirm attachments still work for an agent on an account whose
profile access token is not serialized (e.g. a Cloud plan without
`api_and_webhooks`).
3. Send a `POST` to
`/api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads`
with no credentials, an empty `api_access_token`, or an invalid token,
and confirm it returns `401`.
4. Confirm a valid agent of the account (via API token or session) gets
`200`, while an agent of a different account gets `401`.
2026-07-16 18:17:29 +05:30
Tanmay Deep SharmaandGitHub 2891a72cb9 feat(whatsapp): enable reconfigure for embedded signup inboxes (#15038) 2026-07-16 16:18:58 +05:30
522e3c4d3f feat: enforce api_and_webhooks feature for token API and account webhooks (#14973)
This gates API-token access and outgoing account webhooks behind the
`api_and_webhooks` account feature introduced in #14972. On Chatwoot
Cloud, Hacker accounts lose token-authenticated account API access and
account webhook delivery, while paid accounts retain them through the
billing-plan feature reconcile. Community and self-hosted installations
continue to work without any upgrade-time interruption.

## What changed

- Added `Account#api_and_webhooks_enabled?` as the single backend kill
switch. Core returns enabled; the Enterprise override consults the
account flag on Chatwoot Cloud and remains enabled off-Cloud.
- Account-scoped v1 and v2 requests authenticated with a user or
agent-bot API token now return `403 Forbidden` when the feature is
disabled. Invalid tokens still return 401, and dashboard session
requests are unaffected.
- Profile responses return an empty access token when none of the user's
accounts has access. The stored token is preserved, and the profile UI
disables its token controls with paid-plan copy on Cloud.
- Account webhook delivery stops when the feature is disabled. Webhook
CRUD remains available to session-authenticated dashboard requests,
API-inbox webhooks continue to be delivered, and the Cloud dashboard
shows a webhook paywall instead of the webhook list.
- Removed the database backfill migration. Existing paid Cloud accounts
should be enabled with the one-off script below before enforcement is
deployed.

## Existing paid-account rollout

Run this as an ad-hoc Rails runner script on Chatwoot Cloud. It
intentionally targets only the Startups, Business, and Enterprise plans
and does not add `api_and_webhooks` to `manually_managed_features`, so
future billing reconciles remain authoritative.

```rb
paid_plan_names = %w[Startups Business Enterprise]
accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names)

total = accounts.count
enabled = 0
skipped = 0

puts "Enabling api_and_webhooks for #{total} paid account(s)..."

accounts.find_each(batch_size: 500).with_index(1) do |account, processed|
  if account.feature_enabled?('api_and_webhooks')
    skipped += 1
  else
    account.enable_features!('api_and_webhooks')
    enabled += 1
  end

  puts "Processed #{processed}/#{total}..." if (processed % 1000).zero?
end

puts "Done! Enabled: #{enabled}, Skipped: #{skipped}, Total: #{total}"
```

For example, save the snippet outside the repository as
`enable_api_and_webhooks.rb`, then run:

```sh
bundle exec rails runner /path/to/enable_api_and_webhooks.rb
```

## How to test

- On Cloud, use a Hacker account and confirm token-authenticated
requests to account-scoped v1 and v2 endpoints return 403, while the
same dashboard actions continue to work through session authentication.
- Confirm profile access-token controls are disabled with paid-plan copy
when all accounts are ineligible, and remain available when at least one
account has the feature.
- Confirm the Webhooks settings page shows the billing paywall for a
Cloud account without the feature; admins get the billing action and
agents get the existing ask-an-admin message.
- Confirm outgoing account webhooks stop for an ineligible Cloud account
while API-inbox webhooks still deliver.
- Confirm community and self-hosted installations retain API and webhook
behavior after upgrading, even when an existing account does not have
the stored feature bit.


### Screenshots

## Cloud

<img width="2590" height="642" alt="CleanShot 2026-07-15 at 15 13 14@2x"
src="https://github.com/user-attachments/assets/431a7bd8-1742-4e7a-b312-d3ad92015f9b"
/>

<img width="2152" height="994" alt="CleanShot 2026-07-15 at 15 14 37@2x"
src="https://github.com/user-attachments/assets/475dda48-d1c5-4be5-a3c3-7a96b9713724"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-16 13:43:49 +04:00
Sivin VargheseandGitHub 0fccb7dacd feat: stage edits to published articles as drafts (#14842) 2026-07-16 12:44:10 +05:30
49c442751d fix: require admin for dashboard app mutations (#14831)
## Summary

Restricts account-wide Dashboard App creation, updates, and deletion to
administrators while keeping read access available to authenticated
account users.

## Why

Dashboard Apps are account-level integrations displayed in conversation
views. Agents should be able to use them, but only administrators should
be able to change their configuration.

## What changed

- authorize Dashboard App actions through `DashboardAppPolicy`
- allow index and show access for authenticated account users
- restrict create, update, and destroy actions to administrators
- add request coverage for administrator and agent mutation behavior

## Validation

`bundle exec rspec
spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb`

17 examples, 0 failures.

`bundle exec rubocop
app/controllers/api/v1/accounts/dashboard_apps_controller.rb
app/policies/dashboard_app_policy.rb`

2 files inspected, no offenses detected.



---------

Co-authored-by: Gaurav Singhal <gauravsinghal@Gauravs-Mac-mini.local>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-14 23:02:13 -07:00
11c65f3b9a feat: Intercom import workflow (#14922)
## Description

Adds an admin-only Intercom import workflow under Settings > Data.
Admins can connect an Intercom access token, start named historical
contact/conversation imports, monitor active and previous import runs,
review paginated skip/error logs, download skip logs, and route imported
conversations into source-bucket API inboxes that can be renamed later.

The import path stores durable source mappings, batches Intercom
contact/conversation pages through Sidekiq, records already-imported
records as skipped, and writes historical messages without normal
outbound delivery callbacks. The PR also includes the Intercom import
PRD/TDD document for review context.

Closes
[CW-7519](https://linear.app/chatwoot/issue/CW-7519/explore-intercom-import)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [x] This change requires a documentation update

## How Has This Been Tested?

Tested importing using actual data through integration.

Screenshots:

<img width="1800" height="948" alt="Screenshot 2026-07-02 at 10 48
48 PM"
src="https://github.com/user-attachments/assets/e74d9ed6-0bca-47de-b6ef-e589afcddfde"
/>
<img width="1800" height="1008" alt="Screenshot 2026-07-02 at 10 49
03 PM"
src="https://github.com/user-attachments/assets/1bd12fdb-0a47-4287-ac1d-ea308e70a9cd"
/>
<img width="1800" height="1005" alt="Screenshot 2026-07-02 at 10 49
21 PM"
src="https://github.com/user-attachments/assets/3d8145f5-1794-4cc3-b3fa-de5cd80e6ca3"
/>
<img width="1800" height="1002" alt="Screenshot 2026-07-02 at 10 49
38 PM"
src="https://github.com/user-attachments/assets/6f818efd-4193-43c2-84eb-66970dca4490"
/>


Passed locally:

```sh
eval "$(rbenv init -)" && bundle exec rspec spec/models/data_import_spec.rb spec/jobs/data_import_job_spec.rb spec/requests/api/v1/accounts/data_imports_spec.rb spec/requests/api/v1/accounts/integrations/intercom_spec.rb spec/jobs/data_imports/intercom/import_jobs_spec.rb spec/services/data_imports/intercom/importer_spec.rb spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb spec/services/data_imports/intercom/source_bucket_spec.rb
```

```sh
eval "$(rbenv init -)" && bundle exec rubocop app/controllers/api/v1/accounts/data_imports_controller.rb app/controllers/api/v1/accounts/integrations/intercom_controller.rb app/jobs/data_imports/intercom app/models/data_import.rb app/models/data_import_error.rb app/models/data_import_item.rb app/models/data_import_mapping.rb app/models/integrations/hook.rb app/policies/data_import_policy.rb app/policies/hook_policy.rb app/services/data_imports/intercom db/migrate/20260702000000_expand_data_imports_for_intercom_imports.rb db/migrate/20260702000001_create_data_import_items.rb db/migrate/20260702000002_create_data_import_mappings.rb db/migrate/20260702000003_create_data_import_errors.rb spec/jobs/data_imports/intercom spec/requests/api/v1/accounts/data_imports_spec.rb spec/requests/api/v1/accounts/integrations/intercom_spec.rb spec/services/data_imports/intercom
```

```sh
pnpm exec eslint app/javascript/dashboard/api/dataImports.js app/javascript/dashboard/api/integrations.js app/javascript/dashboard/routes/dashboard/settings/data/Index.vue app/javascript/dashboard/routes/dashboard/settings/data/Show.vue app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js app/javascript/dashboard/routes/dashboard/settings/integrations/Intercom.vue app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js app/javascript/dashboard/routes/dashboard/settings/settings.routes.js app/javascript/dashboard/components-next/sidebar/Sidebar.vue app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue
```

```sh
git diff --check
```

Note: the RSpec boot logs the existing local `chatwoot_dev` purge
warning because other database sessions are open, then continues and
completes with 52 examples, 0 failures.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-13 15:25:07 +05:30
Shivam MishraandGitHub 6b3800e25b fix: pre-chat form contact custom attributes lost for existing contacts (#14956)
Contact custom attributes filled in the pre-chat form (e.g. a CPF field)
were silently lost when the visitor's email or phone matched an existing
contact. The widget sent the attributes in a separate request that raced
the conversation create: creating the conversation merges the widget
contact into the existing contact, so the attribute update landed on the
destroyed contact and vanished without an error. New visitors were
unaffected, which made the bug hard to spot.

The fix sends contact custom attributes inside the same request that
identifies the contact. The widget conversation create endpoint now
accepts `contact.custom_attributes` and applies them through
`ContactIdentifyAction` in the same transaction as the merge, so the
submitted values always land on the surviving contact. The campaign path
folds the attributes into the existing contact update call the same way.

<details>
<summary>Reproduction script (rails runner, concurrent
threads)</summary>

```ruby
# Concurrent reproduction of the pre-chat form race that loses contact
# custom attributes when the visitor's email matches an existing contact.
#
# The old widget fired two unawaited requests on pre-chat submit. Each
# iteration replays them as real concurrent threads:
#   - create thread = POST /widget/conversations: resolves the widget contact,
#     then runs ContactIdentifyAction (which merges the widget contact into the
#     existing contact) inside a transaction held open while the conversation
#     and message are created.
#   - patch thread = PATCH /widget/contact: resolves the widget contact via its
#     contact inbox and applies the custom attributes through
#     ContactIdentifyAction, exactly like Widget::ContactsController#update.
#
# Run with: bundle exec rails runner confirm_prechat_race.rb
# Creates throwaway contacts on Account.first and deletes them afterwards.

ITERATIONS = 20
CPF = '123.456.789-09'.freeze

account = Account.first!
inbox = account.inboxes.first!
losses = 0

ITERATIONS.times do |i|
  existing = account.contacts.create!(name: 'Existing Contact', email: "race-existing-#{SecureRandom.hex(6)}@example.com")
  temp = account.contacts.create!(name: 'Widget Visitor')
  contact_inbox = ContactInbox.create!(contact: temp, inbox: inbox, source_id: SecureRandom.uuid)

  begin
    create_request = Thread.new do
      ActiveRecord::Base.connection_pool.with_connection do
        widget_contact = ContactInbox.find(contact_inbox.id).contact # set_contact before_action
        ActiveRecord::Base.transaction do
          ContactIdentifyAction.new(
            contact: widget_contact,
            params: { email: existing.email, phone_number: nil, name: 'Widget Visitor' },
            retain_original_contact_name: true,
            discard_invalid_attrs: true
          ).perform
          sleep(0.02) # conversation + message creation keeps the transaction open
        end
      end
    end

    patch_request = Thread.new do
      ActiveRecord::Base.connection_pool.with_connection do
        sleep(rand * 0.02) # network jitter between the two requests
        widget_contact = ContactInbox.find(contact_inbox.id).contact # set_contact before_action
        ContactIdentifyAction.new(
          contact: widget_contact,
          params: { custom_attributes: { cpf: CPF } },
          discard_invalid_attrs: true
        ).perform
      end
    end

    create_request.join
    patch_request.join

    survivor = existing.reload
    if survivor.custom_attributes['cpf'] == CPF
      puts "iteration #{i + 1}: CPF survived"
    else
      losses += 1
      puts "iteration #{i + 1}: CPF LOST (survivor custom_attributes: #{survivor.custom_attributes.inspect})"
    end
  ensure
    account.contacts.where(id: [existing.id, temp.id]).find_each(&:destroy!)
  end
end

puts
puts "#{losses}/#{ITERATIONS} iterations lost the CPF -- race #{losses.positive? ? 'confirmed' : 'not reproduced in this run'}"
```

Result: **20/20 iterations lose the CPF** (consistent across repeated
runs). The conversation create holds its transaction open across the
contact merge, so the fast attribute update either resolves the
soon-to-be-destroyed widget contact or blocks on its row lock and then
updates 0 rows — silently, with no error. This is why the customer sees
the loss every time, not intermittently.

Note: the script replays the old two-request flow at the service layer,
so it reproduces the loss even with this fix applied — the fix works by
removing the second request from the widget, not by changing the raced
code paths. The new request spec (`saves contact custom attributes on
the surviving contact when merged into an existing contact`) covers the
fixed contract.

</details>

## Closes

- Reported via support conversation:
https://app.chatwoot.com/app/accounts/1/conversations/84465

## How to reproduce

1. Create a website inbox with a pre-chat form that has a contact custom
attribute field (e.g. CPF).
2. Create a contact with a known email address.
3. As a visitor, open the widget and fill the pre-chat form using that
same email plus a value for the custom attribute.
4. Before: the attribute never appears on the contact profile. After: it
is saved on the existing contact, overwriting any stale value.

## What changed

- `POST /api/v1/widget/conversations` now permits
`contact.custom_attributes` and passes it to `ContactIdentifyAction`,
which deep-merges it atomically with the contact merge.
- The widget pre-chat form sends contact custom attributes inside the
conversation create payload instead of a separate `setCustomAttributes`
call; the campaign path includes them in the `contacts/update` payload.
2026-07-13 13:51:58 +05:30
b560720e08 feat: allow switching embedded signup whatsapp embedded inboxes to manual setup (#14975)
Since embedded signup has been disabled on production, WhatsApp inboxes
that were created through it have no way to be managed going forward.
This PR lets admins transfer an embedded signup inbox to a manual Cloud
API setup: the inbox settings Configuration tab now shows the webhook
verification token and a "Switch to Manual Setup" form (pre-populated
with the Phone Number ID, Business Account ID, and API key stored during
the embedded signup journey) instead of the old Reconfigure button.

Saving the form updates the channel's `provider_config` without the
`source: embedded_signup` marker, so the inbox becomes a regular
manually-configured WhatsApp Cloud inbox. The backend re-validates the
submitted credentials against Meta before accepting the change.

## How to test

1. Open the settings page of a WhatsApp inbox created via embedded
signup → Configuration tab.
2. The Reconfigure button is gone; you see the webhook verification
token and a Switch to Manual Setup form pre-filled with the stored
credentials.
3. Configure the webhook in your own Meta app using the verification
token, enter a permanent access token from that app, and click "Switch
to Manual Setup".
4. On success the page switches to the standard manual configuration
view (verify token, API key update), and messaging continues to work
with the new credentials. Invalid credentials are rejected with an
error.

## What changed

- `ConfigurationPage.vue`: replaced the embedded-signup Reconfigure
section (and the hidden reauthorize component) with the manual transfer
form.
- New `WHATSAPP_MANUAL_TRANSFER_*` translation keys.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-13 13:48:38 +05:30
Vishnu NarayananandGitHub 0a13dbedfb fix: force account picker on microsoft email inbox OAuth (#14794)
## Description

The Microsoft authorize URL had no `prompt` parameter. With Microsoft's
default behavior, a browser carrying an active Microsoft session is
silently signed in to that account. If the same account is already
attached to an inbox in the same Chatwoot account, the callback treats
the OAuth response as a re-auth and routes the user to the existing
inbox settings page instead of letting them create the new inbox they
intended.

Adding `prompt=select_account` interrupts SSO and always presents the
Microsoft account picker, so the user explicitly chooses the mailbox
they want to connect.

This is distinct from `prompt=consent`, which was removed in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) to fix the
admin consent loop. `select_account` only interrupts SSO and does not
retrigger the consent dialog.

Closes INF-76

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

`bundle exec rspec
spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb`
(3 examples, 0 failures). The regression test introduced in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) is updated to
assert `prompt=select_account` instead of asserting absence of the
parameter.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-09 13:49:33 +05:30
Sony MathewandGitHub 66cfb26c77 feat: Add unread count filters feature flag (1/6) (#14885)
## Description

Adds the account-level `unread_count_for_filters` feature flag as the
dark-launch gate for filtered sidebar unread counts. This reuses the
deprecated `quoted_email_reply` flag slot, resets the reused bit for
existing accounts, and removes stale defaults so new accounts do not
reference the old flag.

This also adds the feature where we are now calculating the unread counts for built in filters like mentions, participating and unattended along with unread count for saved filters/folders.

Closes
[CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
2026-07-08 23:50:40 +05:30
Sojan JoseandGitHub 0e07a27c74 fix: enforce inbox limits at model level (#14949)
Fixes https://linear.app/chatwoot/issue/CW-7559/inbox-limit-abuse

## Why

The regular inbox API checked limits in the controller, but WhatsApp
embedded signup creates inboxes through a service using `Inbox.create!`.
That let Enterprise account inbox limits be skipped for embedded signup.

## What this change does

- Adds an Inbox create-time validation hook in OSS and implements the
limit check in the Enterprise Inbox module.
- Removes the duplicate controller/helper limit check so the model is
the single enforcement point.
- Preserves the existing `402 Payment Required` API response for account
inbox limit failures.
- Keeps updates to existing inboxes allowed when an account is already
at its inbox limit.

## Validation

- `bundle exec rspec
spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
spec/enterprise/models/inbox_spec.rb`
2026-07-08 13:18:07 +04:00
Vishnu NarayananandGitHub 1e7218d439 fix: compare HMAC identity hashes securely (#14945)
This switches widget and public inbox HMAC identity checks from direct
string equality to constant-time comparison with a length guard.

Fixes
[https://linear.app/chatwoot/issue/CW-6937](https://linear.app/chatwoot/issue/CW-6937)
Fixes
[https://linear.app/chatwoot/issue/CW-7464](https://linear.app/chatwoot/issue/CW-7464)
Fixes
[https://linear.app/chatwoot/issue/CW-7227](https://linear.app/chatwoot/issue/CW-7227)
2026-07-07 17:37:22 +05:30
c8bfbadae2 fix: validate identifier hash on widget contact update (#14884)
## Description

The widget contact update endpoint (`PATCH /api/v1/widget/contact`)
applied updates that set an `identifier` without validating the
`identifier_hash`, unlike `set_user`, which already does. This change
runs the same validation on the update path whenever an `identifier` is
supplied, keeping identity validation consistent across the widget
contact endpoints.

Anonymous updates that don't pass an identifier (prechat
name/email/phone and custom attributes) keep working unchanged,
including on inboxes with mandatory identity validation.

Fixes https://linear.app/chatwoot/issue/CW-7118


---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-07 16:40:19 +05:30
Aakash BakhleandGitHub 83dda621c5 feat: default new accounts to captain v2 (#14917)
# Pull Request Template

## Description

defaults new accounts to captain v2

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and specs

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-07 16:21:51 +05:30
Sojan JoseandGitHub b823764199 refactor: optimize agent bot assignment dropdown (#14866) 2026-07-07 14:25:54 +05:30
Sivin VargheseandGitHub 34d8741b9b feat: drag to reorder help center articles across pages (#14910) 2026-07-02 15:47:01 +05:30
Sivin VargheseandGitHub 2767bd434b fix: Show a clear error when message translation fails (#14891) 2026-06-30 14:23:47 +05:30
Sony MathewandGitHub b8b62ad0f1 feat: Harden model override preferences (5/6) (#14846)
## Description

Hardens the Captain model override preferences API so account-level
overrides follow the same feature-router contract used by runtime LLM
calls. The API now permits model and feature keys from `llm.yml`,
removes blank model overrides, rejects invalid saved model combinations,
and returns each feature's effective model, provider, and source for UI
clients.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Verified the account preferences API and account model validation
behavior for valid overrides, invalid model values, unknown feature
keys, blank override removal, and effective model/provider/source
payload metadata.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
app/models/concerns/captain_featurable.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:38:11 +05:30
Sony MathewandGitHub 43e0f8a141 feat: Route Enterprise LLM services (3/6) (#14841)
# Pull Request Template

## Description

Routes Enterprise assistant, copilot, FAQ, contact memory,
action-classifier, and false-promise detector LLM paths through
feature-specific model resolution. `Llm::BaseAiService` now accepts
feature/account context and uses `Llm::FeatureRouter` when that context
is present, while retaining the installation-model fallback for
unmigrated callers. This also adds a `document_faq_generation` feature
default for generative FAQ/document content.

Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models
Depends on #14840

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- `bundle exec rspec spec/lib/llm/models_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with 112 examples, 0 failures.
- `bundle exec rspec spec/models/concerns/captain_featurable_spec.rb
spec/models/account_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/lib/llm/feature_router_spec.rb` passed with 87 examples, 0
failures.
- `bundle exec rubocop enterprise/app/services/llm/base_ai_service.rb
enterprise/app/services/captain/copilot/chat_service.rb
enterprise/app/services/captain/llm/assistant_chat_service.rb
enterprise/app/services/captain/llm/faq_generator_service.rb
enterprise/app/services/captain/llm/conversation_faq_service.rb
enterprise/app/services/captain/llm/contact_notes_service.rb
enterprise/app/services/captain/llm/contact_attributes_service.rb
enterprise/app/services/captain/llm/assistant_action_classifier_service.rb
enterprise/app/services/captain/llm/assistant_false_promise_service.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with no offenses.
- `bundle exec ruby -e "require 'yaml'; config =
YAML.load_file('config/llm.yml'); abort('missing
document_faq_generation') unless config.dig('features',
'document_faq_generation'); abort('missing default') unless
config.dig('features', 'document_faq_generation', 'default'); puts
'llm.yml ok'"` passed.
- `git diff --check` passed.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:03:07 +05:30
62cbeae95f feat: onboarding inboxes UI (#14565)
After entering their account details, new admins land on an **Inbox
setup** screen that shows what we've already set up for them and lets
them connect their conversation channels without leaving onboarding. It
surfaces the auto-created live chat widget (and Help Center on
Enterprise), highlights channels detected from their website, and offers
a **View all** dialog to connect any supported channel inline.

### Channel status

| Channel | How it connects | Status | PR |
|---|---|---|---|
| Live chat (Website) | Auto-created during setup |  Done |
https://github.com/chatwoot/chatwoot/pull/14314 |
| WhatsApp | Meta embedded signup |  Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Facebook | Login + page picker |  Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Instagram | OAuth redirect |  Done |
https://github.com/chatwoot/chatwoot/pull/14568 |
| TikTok | OAuth redirect |  Done |
https://github.com/chatwoot/chatwoot/pull/14569 |
| LINE | Inline credential form |  Done | — |
| Telegram | Inline credential form |  Done | — |
| Gmail / Outlook | OAuth (email) | ⚠️ Disabled — coming in a follow-up
| https://github.com/chatwoot/chatwoot/pull/14567 |
| SMS / API / Voice / Other email | — |  Unavailable | — |

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-25 14:54:07 +05:30
74db16158d feat: migrate dyte integration to cloudflare realtimekit (#14752)
Dyte is sunsetting its existing infrastructure after the Cloudflare
acquisition, so this migrates Chatwoot’s video call integration to
Cloudflare RealtimeKit.

The integration now uses Cloudflare Account ID, RealtimeKit App ID, and
a Cloudflare API token with Realtime Admin permissions. Meeting creation
and participant token generation now call Cloudflare’s RealtimeKit APIs,
while the existing Chatwoot call experience remains unchanged for agents
and customers.

This also adds setup-time credential validation, so admins get clearer
errors when the API token is invalid, the Cloudflare account or
permissions are incorrect, or the RealtimeKit App ID does not belong to
the selected account.

Fixes
https://linear.app/chatwoot/issue/PLA-176/migrate-dyte-integration-to-cloudflare-realtimekit

**How to test**

1. Go to Settings → Integrations → Cloudflare RealtimeKit.
2. Add a Cloudflare Account ID, RealtimeKit App ID, and API token with
Realtime Admin permissions.
3. Confirm the integration saves successfully with valid credentials.
4. Try invalid credentials and confirm the error identifies whether the
token, account/permissions, or app ID is wrong.
5. Start a video call from a conversation and confirm the RealtimeKit
meeting opens.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-24 11:04:08 +04:00
Sivin VargheseandGitHub e8edc9ebf5 feat: allow users to set an icon or emoji for teams (#14767) 2026-06-23 20:53:31 +05:30
df79c0bbde feat: exclude stale conversations via assignment policy age threshold (#14766)
## Linear ticket
-
https://linear.app/chatwoot/issue/CW-7137/assignment-v2-backlog-flush-overloads-agents

## Description

Assignment policies now skip stale unassigned conversations
automatically. Each policy carries an age threshold (defaults to 7
days), so auto-assignment only picks up recent backlog instead of
draining very old, forgotten conversations. The threshold is
configurable per policy and can be cleared to assign conversations
regardless of age. Previously this control existed only on Enterprise
capacity policies (`exclude_older_than_hours`); it now lives on the
assignment policy itself, so every V2 inbox benefits without needing a
capacity policy.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- Local UI flows

## Screenshots? 

<img width="645" height="822" alt="image"
src="https://github.com/user-attachments/assets/ec58db86-c1fa-4f9e-be87-2e24ff02e077"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-23 12:22:45 +05:30
41a3ab6dfa feat(companies): add contact company selector (#14496)
Adds a company selector to the contact details form so agents can
associate a contact with an existing company directly from the contact
page.

Closes

- None

Why

Contacts already expose company information through the CRM fields, but
the form only accepted free-text company names. As we split company CRM
work into smaller PRs, this keeps the contact page aligned with the
structured company model while preserving the existing company-name
behavior used by automations.

What changed

- Shows a company dropdown in the contact details form when the
Companies feature is enabled.
- Keeps legacy free-text company names editable when a contact has no
structured `company_id`.
- Allows Enterprise contact create/update APIs to accept account-scoped
`company_id`.
- Syncs `additional_attributes.company_name` when a contact is
associated with a company, including the existing email-domain
auto-association path.
- Serializes `company_id` in the contact model payload so the form can
show the current association.

How to test

1. Enable Companies for an account and open a contact details page.
2. In Edit contact details, use the Company field to select an existing
company.
3. Save the contact and refresh the page.
4. Confirm the selected company remains visible and the contact is
associated with that company.
5. Confirm contacts with only a legacy free-text company name still show
the text input instead of an empty selector.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-16 15:29:27 +05:30
Sivin VargheseandGitHub 1713cabdf2 feat: Add emoji & icon picker for Help Center categories (#14702) 2026-06-16 02:10:05 +05:30
Vishnu NarayananandGitHub ee6382109a feat: prevent deleted email conversations from syncing again (#14612)
# Pull Request Template

## Description

Prevent deleted email conversations from being synced into Chatwoot
again while they are still within the IMAP sync window.

When an admin explicitly deletes an email conversation, the incoming
email message IDs are stored temporarily in Redis. IMAP sync checks
these recently deleted message IDs in addition to existing message
records. Each Redis key expires automatically after two days.

This applies only to explicit conversation deletion. Individual message
deletion, inbox deletion, and account deletion keep their existing
behavior.

Fixes
[CW-7214](https://linear.app/chatwoot/issue/CW-7214/deleted-mails-in-gmail-inbox-gets-synced-again)
2026-06-15 17:06:40 +05:30
Vishnu NarayananandGitHub 92a1fb8ab7 feat: manage active user sessions from profile (CW-7169) (#14556)
## Description

First PR of user_sessions feature - enforcement, impersonation and mfa will be handled separately.

Adds an Active Sessions section under Profile where users can see every
device currently logged in and revoke any session they don't recognize.
Helps users lock down stale or unrecognized logins on their own without
needing support.

**Behavior at the limit, by client:**
- **Browser:** returns 409 with a picker overlay; user picks a session
to revoke or chooses "End all sessions" to clear them.

- **Mobile / API client:** silently evicts the oldest session and
proceeds with login (no picker UI to render).
- **Pre-tracking users** (token rows without `user_sessions`, i.e.
anyone already logged in before this ships): silent-evict any untracked
token first, so freshly tracked sessions are never killed in favor of
legacy ones.

Sessions are stored in a new `user_sessions` table keyed on `(user_id,
client_id)` with browser, platform, IP, last activity and (when
configured) geo. Kept in sync with `user.tokens` via an after_save
callback so revoking a token from any path cleans up the row.

Fixes https://linear.app/chatwoot/issue/CW-7169

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- Added specs.
- Manual local testing: browser picker fires at limit; pre-tracking user
silent-evicts; mixed tracked/untracked correctly drops the untracked one
first; profile page revoke succeeds; current session cannot be revoked
from profile.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-12 16:11:07 +05:30
Shivam MishraandGitHub d9c07fe2e9 feat: expose onboarding help center generation status (#14671)
This PR adds a reload-safe onboarding Help Center generation status
path. Previously, generation progress was pushed through ActionCable,
which meant the onboarding UI could lose context after a page reload or
missed websocket event. The new endpoint exposes the current generation
id, raw Redis generation state, and Help Center article/category counts
so clients can recover state by fetching from the backend.

**What changed**

- Added an onboarding Help Center generation status endpoint.
- Persisted `help_center_generation_id` when generation is enqueued.
- Removed Help Center generation ActionCable broadcasts completely.
- Kept generation progress in Redis as the backend source of truth.
- Kept Help Center generation out of the onboarding hot path; the
existing onboarding controller does not start generation yet.

**How to test**

1. Start Help Center generation for an account.
2. Fetch the onboarding generation status endpoint and verify it returns
the generation id, Redis state, and article/category counts.
3. Reload the onboarding UI or client state and fetch again to confirm
progress can be recovered without relying on websocket events.
4. Verify skipped/completed generation states are reflected from Redis.

## Related PRs

- https://github.com/chatwoot/chatwoot/pull/14569
- https://github.com/chatwoot/chatwoot/pull/14568
- https://github.com/chatwoot/chatwoot/pull/14567
- https://github.com/chatwoot/chatwoot/pull/14619
- https://github.com/chatwoot/chatwoot/pull/14565 (Primary onboarding
PR)
2026-06-10 16:24:37 +05:30
64c5aeebee feat(help-center): support per-locale portal title, name & header (#14642)
Portals can now override their name, page title and header text per
locale, with a fallback chain of locale override → default locale → base
value. Overrides are stored under portal.config.locale_translations and
validated with a JSON schema.

Editing is exposed as a "Localize content" action in each non-default
locale's menu on the Locale page, and all public-facing portal views
(classic + documentation layouts) render the localized values. The live
chat widget is also loaded in the active portal locale.


<img width="494" height="395" alt="Screenshot 2026-06-03 at 2 48 59 PM"
src="https://github.com/user-attachments/assets/e55a06e8-f20f-4d8a-9352-92fde28a9a19"
/>



https://github.com/user-attachments/assets/f5affbd2-7ad4-415a-b376-57c759fc2aaa

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:34:12 -07:00
Vinícius FitznerandGitHub b791d75b30 fix(microsoft): prevent OAuth admin consent loop (#13962)
Fixes #9775

## Description

This fixes a repeated admin consent loop in the Microsoft OAuth flow
when connecting a Microsoft email inbox.

Chatwoot was always sending `prompt=consent` in the Microsoft
authorization URL. In the current code path, this parameter is only used
when building the authorization URL and is not required by the callback,
token exchange, token persistence, or refresh flow.

By removing the forced consent prompt, the OAuth flow can proceed
normally without repeatedly sending users back through the admin consent
screen.

## What changed

- removed `prompt: 'consent'` from the Microsoft authorization URL
- added a regression assertion to ensure `prompt` is not included in the
generated URL

## Why this is safe

- `redirect_uri`, `scope`, and `state` remain unchanged
- callback and token exchange flow remain unchanged
- refresh token flow remains unchanged
- no other part of the current Microsoft inbox flow depends on forcing a
consent screen

## Testing

- updated controller spec to assert that the generated authorization URL
does not include `prompt`
2026-06-03 12:05:25 +05:30
Sony MathewandGitHub 87df43bdd0 revert: restore conversation unread count feature flag (#14623)
This reverts #14610 so conversation unread counts are again controlled
by the `conversation_unread_counts` feature flag across the API,
ActionCable broadcasts, notifier/listener paths, and dashboard sidebar
fetching.

## Closes
- None

## What changed
- Restores feature-flag checks for conversation unread count reads and
broadcasts.
- Restores the dashboard feature flag constant and sidebar/store
behavior for disabled unread counts.
- Restores the specs that cover disabled-feature behavior.

## How to test
- In an account with `conversation_unread_counts` enabled, verify
sidebar unread counts are fetched and updated in real time.
- Disable `conversation_unread_counts` for the account and verify unread
count requests/broadcasts are skipped.
2026-06-02 21:11:48 +05:30
95cb3a7ad8 feat(onboarding): honor return_to hint in Instagram OAuth callback (#14568)
When connecting an Instagram inbox during onboarding, the OAuth flow
used to drop users in inbox settings, breaking onboarding. The OAuth
start endpoint now accepts an optional `return_to=onboarding` hint,
carried tamper-proof inside the signed state (a claim on the Instagram
JWT), and the callback uses it to return the user to the onboarding
inbox-setup screen. Without the hint, behavior is unchanged.

This is the backend half only; the frontend that sends
`return_to=onboarding` ships separately.

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-02 15:28:08 +05:30
Sony MathewandGitHub 88e2661ca6 feat(conversations): remove unread count feature flag (CW-7237) (#14610)
## Description

Make conversation unread counts always available at runtime by removing
account feature checks from the API endpoint, unread-count listener,
notifier, and ActionCable broadcast path.

Update the dashboard to fetch sidebar unread counts for the active
account without checking FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS, and
remove the now-unused store clear action that only supported the
disabled state.

Keep the feature entry in config/features.yml to preserve flag bit
order, but mark it enabled and deprecated so fresh installs default to
the always-on behavior while feature-management UI hides it.

Leave existing installation default rows untouched; no migration is
included, so upgraded installs may still store the old flag value but
runtime behavior no longer depends on it.

Update specs around the new always-on contract and remove obsolete
disabled-feature assertions.

Fixes # CW-7237

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Update specs around the new always-on contract and remove obsolete
disabled-feature assertions. Ran specs locally for the changes.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-02 14:35:37 +05:30
4c6a345d60 feat(onboarding): honor return hint in email OAuth callback (#14567)
When connecting a Gmail or Outlook inbox during onboarding, the OAuth
flow used to drop users in inbox settings, breaking onboarding. The
OAuth start endpoint now accepts an optional `return_to=onboarding`
hint, carried tamper-proof inside the signed `state`, and the callback
uses it to return the user to the onboarding inbox-setup screen. Without
the hint, behavior is unchanged.

This is the backend half only; the frontend that sends
`return_to=onboarding` ships separately.

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-02 14:21:11 +05:30
1f6203d558 feat(onboarding): honor return_to hint in TikTok OAuth callback (#14569)
When connecting a TikTok inbox during onboarding, the OAuth flow used to
drop users in inbox settings, breaking onboarding. The OAuth start
endpoint now accepts an optional `return_to=onboarding` hint, carried
tamper-proof inside the signed `state` (a claim on TikTok's signed JWT),
and the callback uses it to return the user to the onboarding
inbox-setup screen. Without the hint, behavior is unchanged.

This is the backend half only; the frontend that sends
`return_to=onboarding` ships separately.

## What changed
- `Tiktok::IntegrationHelper`: the signed JWT carries an optional
`return_to` claim, added only when present (a request without it is
byte-identical to before); added `tiktok_token_return_to` to read it;
`decode_token` now returns the full payload and `verify_tiktok_token`
derives the account id from it.
- `Tiktok::AuthorizationsController#create` passes `params[:return_to]`
into the token.
- `Tiktok::CallbacksController` redirects to the onboarding inbox-setup
screen when `return_to == 'onboarding'`, before the normal
settings/agents redirect.
- Added the `app_onboarding_inbox_setup` route (shared with the sibling
Gmail/Outlook and Instagram PRs — keep a single copy on merge to avoid a
duplicate route name).

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-02 13:24:46 +05:30
Shivam MishraandGitHub a3ffb48a47 refactor(onboarding): use separate onboarding controller (#14507)
Depends on: https://github.com/chatwoot/chatwoot/pull/14370

This PR creates a new onboarding controller, this allows more control
that the default account update API. Allowing us to spin tasks and
update details required specifically during the onboarding flow
2026-06-01 13:34:11 +05:30
Sivin VargheseandGitHub b33eecaff7 feat: bulk change category for articles (#14443) 2026-05-26 17:32:58 +05:30
6b1d8203c6 fix: remove unused working hours endpoint (#13839)
Fixes #13752

Removes the standalone `working_hours` API endpoint instead of fixing
only the callback typo in `WorkingHoursController`.

## Why

The route is not used by the dashboard. The supported product flow
already updates business hours through `PATCH
/api/v1/accounts/:account_id/inboxes/:id`.

The standalone endpoint was already unusable in practice:
- The controller callback pointed to the wrong method.
- Fixing that callback alone would still leave the endpoint blocked by
missing `WorkingHourPolicy` authorization.
- Keeping the route would preserve unsupported API surface without
making the product flow better.

## What this change does

- Removes `PATCH/PUT /api/v1/accounts/:account_id/working_hours/:id`.
- Deletes `Api::V1::Accounts::WorkingHoursController`.
- Leaves the inbox working-hours update path unchanged.

Compatibility note: this removes an undocumented endpoint that was
already unusable in practice. Working-hours updates should continue to
go through the supported inbox update API.

## Validation

- Ran `bin/rails routes -g working_hours` and confirmed the standalone
working-hours API route is no longer present.
- Searched for remaining `WorkingHoursController` and `resources
:working_hours` references.

---------

Co-authored-by: easonysliu <easonysliu@tencent.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-22 13:41:09 +05:30
27f2c2b392 feat: Unread Count: added api, store refresher, invalidation and events (2/3)[CW-6851] (#14369)
# Pull Request Template

## Description

This is the second PR in a series of PRs for Introducing unread counts
in the sidebar for inboxes and labels.

In this PR:

* added api for unread counts 
* Added the store refresher and invalidation with event listeners
* Added action cable event
* Added specs for the changes

Issue:
https://linear.app/chatwoot/issue/CW-6851/support-unread-conversation-counts

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-20 17:36:09 +05:30
6560dbb68d feat: Add an option on the dashboard to allow switching help center layout (#14491)
<img width="633" height="431" alt="Screenshot 2026-05-18 at 12 32 55 PM"
src="https://github.com/user-attachments/assets/682d4c5f-4c76-465b-8d2f-92fbc2bb2a40"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-05-19 06:42:48 -07:00
5f6bd951b9 fix: portals#create returns 500 when custom_domain is omitted (#14400)
## Description

`POST /api/v1/accounts/:account_id/portals` returns a generic 500
(`{"status":500,"error":"Internal Server Error"}`) whenever the request
body omits `custom_domain`. Root cause: `parsed_custom_domain` calls
`URI.parse(@portal.custom_domain)` and `URI.parse(nil)` raises
`URI::InvalidURIError`. Existing callers either had to know to pass
`"custom_domain": ""` as a workaround or hit a 500 with no useful
diagnostic.

This PR guards `parsed_custom_domain` against blank values so the
existing fall-through (`else @portal.custom_domain`) applies —
equivalent to passing an empty string.

It also moves the `process_attached_logo` guard from the helper into the
`create` call site so `create` mirrors `update` (`process_attached_logo
if params[:blob_id].present?`) and avoids an unnecessary signed-blob
lookup on every create that doesn't include a logo.

Fixes #14397

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Two new request specs in
`spec/controllers/api/v1/accounts/portals_controller_spec.rb` covering
the regression:

- `creates portal when custom_domain is omitted from request body` — the
previously-broken case, now returns 200.
- `creates portal when custom_domain is blank` — verifies the existing
workaround (`"custom_domain": ""`) still works after the change.

Manually verified against `chatwoot/chatwoot:latest` Docker image before
the fix (500) and against this branch (200) using the curl repro from
the issue.

```bash
curl -X POST "https://<host>/api/v1/accounts/<account_id>/portals" \
  -H "Content-Type: application/json" \
  -H "api_access_token: <token>" \
  -d '{"name":"Test Portal","slug":"test-portal","color":"#3b82f6"}'
```

Before: `{"status":500,"error":"Internal Server Error"}`
After: `200 OK` with the portal payload.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation (no doc
change needed — controller behaviour, fully backward-compatible)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-15 11:11:18 +05:30
dc332dd93e feat: add attachments endpoint for contact media view (#14391)
# Pull Request Template

## Description

This PR adds an endpoint to fetch all attachments shared with or by a
contact across all of their conversations.

Results are scoped based on the access:
* Admins can access all attachments
* Agents can access attachments only from inboxes they belong to
* Custom role agents are further filtered based on their conversation
permissions

Each attachment payload includes `conversation_id`, allowing the UI to
deep-link back to the source conversation.

Added `GET
/api/v1/accounts/:account_id/contacts/:contact_id/attachments` under the
existing contacts scope.

Fixes
https://linear.app/chatwoot/issue/CW-7021/add-media-view-to-the-contact-details-page

## Type of change

- [x] New feature (non-breaking change which adds functionality)


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-14 21:34:39 +05:30
13f66e3a88 fix: incorrect scope across controllers (#14459)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-14 20:34:18 +05:30
dd7f5c27e5 perf: eliminate N+1 queries on inboxes#index (#14451)
The `inboxes#index` API endpoint was firing 3 queries per inbox while
rendering the response — one each for the polymorphic channel, avatar
attachment, and working hours. For accounts with many inboxes this
turned a routine list call into a multi-second request — measured at 35
seconds on an account with 5,000 inboxes.

Two root causes:
1. `policy_scope` was silently discarding the controller's
`.includes(...)` chain because `InboxPolicy::Scope#resolve` returns a
fresh `user.assigned_inboxes` relation, ignoring whatever scope is
passed in. So the eager loading never actually applied.
2. `Inbox#weekly_schedule` called `working_hours.order(...).select(...)`
which fires a new query per inbox even when the association is
preloaded.

The fix chains the eager loads and ordering after `policy_scope` so they
apply to the relation the policy actually returns, adds `:portal` and
`:working_hours` to the preload list, and refactors `weekly_schedule` to
sort the preloaded collection in Ruby and build the hash directly.

Result: queries drop from O(n) to a constant ~15 regardless of inbox
count, and total request time drops 5–6× across every account size
tested. Response payload is byte-identical.

## How to test

1. Open the agent dashboard for an account with a large number of
inboxes (1000+). On a stock dev DB you can seed quickly with
`Seeders::AccountSeeder` or by creating a few hundred `Channel::Api`
inboxes via the rails console.
2. Hit any UI surface that lists inboxes (settings → inboxes, sidebar
inbox list, agent assignment dropdowns). Should feel near-instant where
it previously hung.
3. Confirm every inbox shows the same data as before — channel type,
working hours, avatar, portal/help-center link. No fields should be
missing or different.

## Benchmarks

Real HTTP requests via curl against a dev Rails server (median of 5
timed trials after warmup; "Server total" is the time reported in Rails'
`Completed 200 OK in <X>ms` log line).

| Account | Channel mix | OLD | NEW | Speedup |
|---|---|---|---|---|
| 1000 inboxes | 3 types, no portals/avatars | 5.87 s | 0.96 s |
**6.1×** |
| 5000 inboxes | 3 types, no portals/avatars | 33.07 s | 6.93 s |
**4.8×** |
| 5000 inboxes | 10 types, 25% portals, 10% avatars | 35.04 s | 6.55 s |
**5.4×** |

Detailed breakdown for the realistic 5000-inbox case:

|                 | OLD       | NEW      | Δ        |
|-----------------|-----------|----------|----------|
| Server total    | 35,042 ms | 6,550 ms | −81%     |
| Views           | 32,034 ms | 6,452 ms | −80%     |
| ActiveRecord    | 2,948 ms  | 92 ms    | −97%     |
| Allocations     | 42.3 M    | 11.3 M   | −73%     |
| Queries         | ~15,000   | 15       | −99.9%   |

## What changed

- `app/controllers/api/v1/accounts/inboxes_controller.rb` — chain
`.includes(:channel, :portal, :working_hours, avatar_attachment:
:blob).order_by_name` *after* `policy_scope(...)`. The previous code
passed them into `policy_scope` but the policy resolver dropped the
chain.
- `app/models/concerns/out_of_offisable.rb` — `weekly_schedule` now
sorts the preloaded `working_hours` collection in Ruby and constructs
the result hashes inline, avoiding both the per-inbox query from
`.order(...).select(...)` and the `as_json` reflection overhead.

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-05-14 14:20:52 +05:30