439ac800193634c8d2d476e2e77bbcc1fabf476a
869
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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. |
||
|
|
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
|
||
|
|
331875cdaa | feat: Add popular content per locale (#14939) | ||
|
|
5af26e45a9 | chore: Cleanup help center layouts (#14812) | ||
|
|
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`. |
||
|
|
2891a72cb9 | feat(whatsapp): enable reconfigure for embedded signup inboxes (#15038) | ||
|
|
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> |
||
|
|
0fccb7dacd | feat: stage edits to published articles as drafts (#14842) | ||
|
|
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> |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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> |
||
|
|
20227403ce |
fix: return public contact inbox payload after update (#14946)
# Pull Request Template ## Description This keeps the public inbox contact update response on the public contact inbox serializer shape after applying the contact identify update. ## 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 to not work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? - `bundle exec rspec spec/controllers/public/api/v1/inbox/contacts_controller_spec.rb` ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [ ] I have commented 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 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-7457](https://linear.app/chatwoot/issue/CW-7457) |
||
|
|
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 |
||
|
|
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 |
||
|
|
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` |
||
|
|
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) |
||
|
|
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> |
||
|
|
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 |
||
|
|
eab859cd04 |
fix: Sanitize query canceled API errors (#14933)
# Pull Request Template ## Description Message creation API failures caused by PostgreSQL query cancellations now return a generic retryable error instead of exposing raw database internals such as `PG::QueryCanceled`, tuple identifiers, or relation names to customers. Existing validation failures continue to return their specific validation messages. Closes [CW-7538](https://linear.app/chatwoot/issue/CW-7538/do-not-expose-pgquerycanceled-details-in-messages-api-errors) ## 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? Reproduced the messages API path by raising `ActiveRecord::QueryCanceled` from `Messages::MessageBuilder` and verified the response contains the customer-safe localized error without `PG::QueryCanceled` details. Validation run locally: - `bundle exec rspec spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb` - `bundle exec rubocop app/controllers/concerns/request_exception_handler.rb spec/controllers/api/v1/accounts/conversations/messages_controller_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 - [ ] 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 |
||
|
|
b823764199 | refactor: optimize agent bot assignment dropdown (#14866) | ||
|
|
6a7ca9dd3b |
feat: Add report bar drilldown drawer (#14626)
## Description Adds drilldown support for report bar charts powered by `ReportContainer`. Clicking a non-zero report bar now opens a right-side drawer with the conversations or messages that contributed to that bucket, with each row linking to the underlying conversation and message rows linking with `messageId`. This includes a new `GET /api/v2/accounts/:account_id/reports/drilldown` endpoint, backend drilldown builders/serializers, generic chart click emission, local drawer state via `useReportDrilldown`, compact drilldown cards, pagination, stale-response protection, and validation for unsupported drilldown dimensions. Fixes # CW-4497 https://linear.app/chatwoot/issue/CW-4497/drill-down-on-agent-conversations-report ## Type of change - [x] 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? Ran the focused backend and frontend checks for the drilldown endpoint, builder, chart click handling, drawer/card UI, API helper, and stale-response handling. Here are the screenshots on how it looks like: <img width="1792" height="1199" alt="Screenshot 2026-06-02 at 11 32 11 PM" src="https://github.com/user-attachments/assets/6bdb8832-b9df-4bf3-9a2a-beaefe203b6e" /> <img width="1791" height="1230" alt="Screenshot 2026-06-02 at 11 32 34 PM" src="https://github.com/user-attachments/assets/36e92eb7-3208-4855-87f4-0c7f316df54d" /> <img width="1784" height="1235" alt="Screenshot 2026-06-02 at 11 32 46 PM" src="https://github.com/user-attachments/assets/f7a53916-74f2-4622-9305-042e0ac9e877" /> ## 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: Vishnu Narayanan <iamwishnu@gmail.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com> |
||
|
|
34d8741b9b | feat: drag to reorder help center articles across pages (#14910) | ||
|
|
2767bd434b | fix: Show a clear error when message translation fails (#14891) | ||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
f9cc702030 |
fix(help-center): documentation layout on custom domain root, locale (#14850)
Help centers using the documentation layout now render correctly in two cases that previously fell back to the wrong output. Opening a portal at its custom-domain root (e.g. docs.example.com/ ) now shows the full documentation home in place instead of the classic layout, and portals whose locale is a region variant that Chatwoot doesn't ship a translation for (e.g. th_TH , fr_ML ) now show their categories in the sidebar on article pages. Closes https://linear.app/chatwoot/issue/CW-7437/portal-layout-is-not-properly-working-in-custom-domain ## How to test 1. Create a portal with the documentation layout and a custom domain (e.g. example.chat.test ), with at least one category containing a published article. 2. Visit the custom-domain root ( http://example.chat.test:3000/ ) → it should show the full documentation home (sidebar, topbar), not the classic layout, with no redirect. 3. Set the portal's locale to a region variant Chatwoot doesn't translate, e.g. th_TH , with categories/articles under that locale. 4. Open an article → the sidebar should list the article's category and sibling articles. Before the fix the sidebar was empty for these locales. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
e8edc9ebf5 | feat: allow users to set an icon or emoji for teams (#14767) | ||
|
|
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> |
||
|
|
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> |
||
|
|
de137e8297 |
feat: Capture CTWA referral metadata for WhatsApp conversations (#14681)
Adds support for capturing Click-to-WhatsApp ad referral metadata from incoming WhatsApp messages. This stores Meta Cloud API `referral` payloads on the incoming message `content_attributes` and normalizes Twilio `Referral*` callback fields into the same shape. The UI display is intentionally deferred until Instagram, Messenger, and TikTok referral payloads are captured as well, so we can design one cross-channel referral surface instead of a WhatsApp-only sidebar block. Why message-level storage Meta sends CTWA referral details as part of the inbound message payload, not as a stable conversation-level webhook. The referral represents the exact ad click that produced that specific customer message, and later messages in the same conversation may not carry the same context. Storing the normalized referral on the message preserves the original webhook semantics, avoids adding conversation-level attribution that could become stale or ambiguous, and keeps support for both Cloud API and Twilio payloads aligned behind `content_attributes.referral`. Fixes https://linear.app/chatwoot/issue/CW-7090/surface-meta-ctwa-referral-on-incoming-whatsapp-messages-cloud-api Closes https://github.com/chatwoot/chatwoot/issues/13995, https://github.com/chatwoot/chatwoot/issues/12560, https://github.com/chatwoot/chatwoot/issues/13006 Related community PRs - https://github.com/chatwoot/chatwoot/pull/13130 - https://github.com/chatwoot/chatwoot/pull/14180 - https://github.com/chatwoot/chatwoot/pull/14121 Related follow-ups - https://linear.app/chatwoot/issue/CW-7301/capture-instagram-ad-referral-metadata-on-incoming-messages - https://linear.app/chatwoot/issue/CW-7302/capture-messenger-ad-referral-metadata-on-facebook-page-conversations - https://linear.app/chatwoot/issue/CW-7303/capture-tiktok-ad-referral-metadata-from-im-referral-msg-events How to test 1. Send or replay a WhatsApp Cloud API inbound message that contains a `messages[0].referral` payload from a Click-to-WhatsApp ad. 2. Confirm the generated incoming message stores the payload under `content_attributes.referral`. 3. Repeat with a Twilio WhatsApp callback containing `Referral*` fields and confirm the stored message has the same normalized `content_attributes.referral` structure. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
1713cabdf2 | feat: Add emoji & icon picker for Help Center categories (#14702) | ||
|
|
396631ad7d |
feat: enforce concurrent session limit with login picker (CW-7169) (#14621)
## Description Cap active sessions at `MAX_USER_SESSIONS` which defaults to existing value of `25` per user. This ensure existing user login behavior is not affected for self-hosted installations. Browser users at the cap see a session picker (409 response) to choose which session to end. Non-browser clients and partially-tracked users get silent oldest-session eviction. Depends on #14556. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Specs cover: under limit, at limit (browser picker, non-browser eviction), partial tracking fallback, revoke single/all sessions during login, session row creation on successful login. --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> |
||
|
|
ba0ba46c9c |
fix: skip session tracking and use short-lived token for impersonation (CW-7169) (#14622)
## Description SuperAdmin impersonation SSO logins no longer create UserSession rows visible to the customer. Impersonation tokens use a 2-day lifespan instead of ~2 months, so they naturally evict first and don't linger in the user's token list. Server-side detection via Redis value (`'impersonation'` vs `'normal'`) without changing the `valid_sso_auth_token?` signature. Backward compatible with in-flight tokens. Depends on #14556. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Specs cover: impersonation login skips UserSession creation, impersonation token has short lifespan, normal SSO login still creates UserSession row. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [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 |
||
|
|
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) |
||
|
|
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 |
||
|
|
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) |
||
|
|
f9385a31fc |
fix(api): allow agent bots to read conversations and manage labels (#14655)
Agent bots assigned to a conversation can now fetch that conversation's details and manage its labels using their bot token. Previously these two operations returned `Access to this endpoint is not authorized for bots`, even though the same token worked for sending messages, updating the conversation, and setting custom attributes. Fixes https://linear.app/chatwoot/issue/PLA-174/agent-bot-tokens-cannot-fetch-conversation-details-or-manage-labels Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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` |
||
|
|
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. |
||
|
|
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> |
||
|
|
ecd9c26c8c |
feat: Implemented search results page functionality (#11086)
# Pull Request Template
## Description
Implemented search results page functionality. Now you can press "Enter"
to search by term and display results in a results page. Also now you
can link to /hc/{account}/en/search?query=XXXXXX to view search results
for XXXXXX query.
fixes: https://github.com/chatwoot/chatwoot/issues/10945
## Screenshots
Classic layout search results:
<img width="3840" height="2160" alt="classic-results"
src="https://github.com/user-attachments/assets/3bbb3272-33ca-4eb4-b80a-76ed77442088"
/>
Classic layout pagination:
<img width="3840" height="2160" alt="classic-page-two"
src="https://github.com/user-attachments/assets/062b09d3-7c58-4d3b-8611-b94375e7db51"
/>
Classic layout empty search:
<img width="3840" height="2160" alt="no-results"
src="https://github.com/user-attachments/assets/c5e3f47a-cd9a-4e14-ae92-ccba00c89e98"
/>
Documentation layout search results:
<img width="3840" height="2160" alt="documentation-results"
src="https://github.com/user-attachments/assets/9e45d8d9-c975-4589-b6c6-3bc7bb3c588e"
/>
Documentation layout dark theme:
<img width="3840" height="2160" alt="documentation-dark"
src="https://github.com/user-attachments/assets/cdb6ed63-4241-4b32-9f79-7d92ed479fc8"
/>
Plain embedded dark layout:
<img width="3840" height="2160" alt="plain-embedded-dark"
src="https://github.com/user-attachments/assets/7deb02b9-9f24-48fb-8979-a2ecd7002c05"
/>
---------
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
|
||
|
|
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 |
||
|
|
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> |
||
|
|
04ac9d3780 |
fix: use UPN for imap_login on Microsoft OAuth callback (#14522)
Outgoing email on a Microsoft email inbox was failing with `535 5.7.3 Authentication unsuccessful` immediately after the user re-authenticated, while incoming (IMAP) continued to work. The root cause is in our OAuth callback: we persist the id_token's `email` claim into `channel.imap_login`, but Microsoft's SMTP AUTH (XOAUTH2) validates the username against the access token's **UPN**, not the mailbox's primary SMTP address or aliases. When those diverge — common in tenants that use one domain for sign-in identities and another for mailbox addresses — SMTP rejects every send. This PR makes `OauthCallbackController#update_channel` prefer `preferred_username` (v2.0) / `upn` (v1.0) from the id_token over `email`, with `email` as the fallback so Google flows are unchanged. ## What changed - `app/controllers/oauth_callback_controller.rb` — extract `imap_login_identity` (default: `users_data['email']`, same as before). `update_channel` now calls it instead of inlining the email claim. No behavioural change in the base controller. - `app/controllers/microsoft/callbacks_controller.rb` — override `imap_login_identity` to return `preferred_username || upn || super`. Provider-specific knowledge stays in the provider subclass; Google's flow is literally untouched. - `spec/controllers/microsoft/callbacks_controller_spec.rb` — adds one example that reproduces the divergent shape (`email: <alias>`, `preferred_username: <upn>`) and asserts `imap_login` lands on the UPN while `channel.email` stays on the mailbox alias. `channel.email` and `find_channel_by_email` still key on the id_token's `email` claim everywhere, so customer-facing From identity and reconnect-by-email matching are unchanged. Behavioural matrix: | Provider / shape | `imap_login` before | `imap_login` after | |-----------------------------------------------------------------|---------------------|--------------------| | Microsoft, `upn == email` (common case) | email | UPN (= email, same string) | | Microsoft, `upn != email` (e.g. UPN on one domain, mailbox alias on another) | alias (broken) | UPN (works) | | Google | email | email (no change, base impl used) | ## Why this happens (Microsoft side, for context) Two facts that together produce the bug — one is documented, the other we verified empirically because Microsoft's docs don't address it: 1. **`email` and `upn` are different claims and can legitimately diverge.** In Entra, the UPN is the sign-in identity; the id_token's `email` claim is the user's mailbox property (which can be a proxy address). For v2.0 tokens (which is what we use — `/common/oauth2/v2.0/token` in `MicrosoftConcern`), the documented "username to sign in as" claim is `preferred_username`. See [ID token claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference). So `users_data['email']` was the wrong source for `imap_login`; tenants where UPN == primary SMTP happened to mask the bug. 2. **Exchange's XOAUTH2 SMTP rejects aliases in the `user=` field, even though IMAP accepts them.** This asymmetry is **not** in [Microsoft's canonical XOAUTH2 doc](https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth) — the doc treats the SASL `user=` field uniformly across IMAP, POP, and SMTP, with only a shared-mailbox carve-out spelled out. We confirmed the SMTP-strict behaviour empirically by running an XOAUTH2 AUTH probe with the same access token: `user=<alias>` returned `535 5.7.3`, `user=<UPN>` returned `235`. Same token, only the `user=` field differed. That's what tied the symptom to claim-shape. Hence the patch: read the documented "sign-in name" claim and persist that, not the customer-facing mailbox address. ## How to reproduce (before the fix) 1. Set up a Microsoft email inbox in a tenant where the user's UPN domain differs from the user's primary SMTP / mailbox domain (e.g. UPN `user@tenant-a.example`, mailbox `User@tenant-b.example` where `tenant-b.example` is a proxy address on the same mailbox). 2. Connect or re-authenticate the inbox through the standard flow. 3. Inspect the channel: ```ruby ch.imap_login # => "User@tenant-b.example" (alias — wrong) token = ch.provider_config['access_token'] JSON.parse(Base64.urlsafe_decode64(token.split('.')[1].then { |s| s + '=' * (-s.length % 4) }))['upn'] # => "user@tenant-a.example" (UPN — what SMTP actually needs) ``` 4. Send a reply. SMTP returns `535 5.7.3 Authentication unsuccessful`. Incoming IMAP continues to work fine. After the fix, `imap_login` is set to the UPN at callback time and SMTP succeeds. ## Notes for review - The id_token decoding path (`users_data`) already exists and is trusted by the rest of the callback — no new attack surface. - Scoped to Microsoft on purpose. A provider-agnostic fallback chain in the base controller would also have worked (Google id_tokens don't carry `preferred_username` / `upn`, so it'd land on email anyway), but keeping Google's code path identical to today removes any risk of an unintended interaction with whatever a future Google update might add to its id_token. Pattern matches the existing `find_channel_by_email` override Google already has. - The [id_token claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference) warns that `preferred_username` is mutable and "can't be used to make authorization decisions." That warning targets apps using the claim as a stable cross-session identifier for app-level authz — we're not. We use it as the SASL `user=` string for SMTP AUTH, validated by Microsoft against the same token it just issued. The claim's mutability matters only if a tenant admin renames a UPN between re-auths, in which case the stored `imap_login` goes stale and SMTP 535s — but the pre-patch code has the identical mutability characteristic on the `email` claim (rename a mailbox's primary SMTP and you get the same stale-then-535). The patch doesn't enlarge that failure surface; it shrinks it, because UPN-vs-alias divergence is now handled rather than always-broken. - Related but out of scope: SMTP failures don't trigger `channel.authorization_error!` today. `ExceptionList::SMTP_EXCEPTIONS` (`lib/exception_list.rb`) only contains `Net::SMTPSyntaxError`; `Net::SMTPAuthenticationError` bubbles unhandled, and `ApplicationMailer#handle_smtp_exceptions` only logs. So a 535 — whether from this bug or any other identity drift — never prompts re-auth in the UI, unlike the IMAP path (`fetch_imap_emails_job` catches `OAuth2::Error` and calls `authorization_error!`). Worth a separate PR; flagging here so we don't pretend stale-identity SMTP errors are self-healing. - The alternative I considered — decoding the access token's `upn` directly — is more correct in principle but relies on Microsoft access tokens being JWTs, which is undocumented behaviour for the `outlook.office.com` audience and contradicts the [docs guidance](https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens) that access tokens are opaque to clients. - **Not addressed here, flagging for follow-up:** existing channels that were re-authenticated before this fix still hold the alias in `imap_login` and will keep 535ing until the user re-auths again. A one-shot rake task could decode the stored access tokens and reconcile, but fixing forward via natural re-auth is lower-risk; let me know if you want the backfill. - Also out of scope: `app/mailers/conversation_reply_mailer_helper.rb` reads `provider_config['access_token']` raw without going through `Microsoft::RefreshOauthTokenService`, while the IMAP path refreshes. That's a real asymmetry but unrelated to this bug (the token here was minutes-old) and worth its own PR. |
||
|
|
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> |