From a3ffb48a47aca9b1123fb502fe3c259bc3847256 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 1 Jun 2026 13:34:11 +0530 Subject: [PATCH 01/45] 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 --- .../api/v1/accounts/onboardings_controller.rb | 36 ++++++ app/controllers/api/v1/accounts_controller.rb | 1 - app/javascript/dashboard/api/onboarding.js | 14 +++ .../dashboard/composables/useAccount.js | 5 + .../routes/dashboard/onboarding/Index.vue | 39 ++++-- .../dashboard/store/modules/accounts.js | 10 ++ config/routes.rb | 1 + .../accounts/onboardings_controller_spec.rb | 114 ++++++++++++++++++ .../api/v1/accounts_controller_spec.rb | 10 -- 9 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 app/controllers/api/v1/accounts/onboardings_controller.rb create mode 100644 app/javascript/dashboard/api/onboarding.js create mode 100644 spec/controllers/api/v1/accounts/onboardings_controller_spec.rb diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb new file mode 100644 index 000000000..77c6245ea --- /dev/null +++ b/app/controllers/api/v1/accounts/onboardings_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController + before_action :check_admin_authorization? + + def update + @account = Current.account + finalize = finalizing_account_details? + + @account.assign_attributes(account_params) + @account.custom_attributes.merge!(custom_attributes_params) + @account.custom_attributes.delete('onboarding_step') if finalize + @account.save! + + # TODO: re-enable when the help center generation UI is ready to surface progress + # Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present? + + render 'api/v1/accounts/update', format: :json + end + + private + + def finalizing_account_details? + @account.custom_attributes['onboarding_step'] == 'account_details' + end + + def website + custom_attributes_params[:website] + end + + def account_params + params.permit(:name, :locale) + end + + def custom_attributes_params + params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website) + end +end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 865b387b9..fb991949a 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -58,7 +58,6 @@ class Api::V1::AccountsController < Api::BaseController @account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email)) @account.custom_attributes.merge!(custom_attributes_params) @account.settings.merge!(settings_params) - @account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details' @account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update' @account.save! end diff --git a/app/javascript/dashboard/api/onboarding.js b/app/javascript/dashboard/api/onboarding.js new file mode 100644 index 000000000..2bc098eef --- /dev/null +++ b/app/javascript/dashboard/api/onboarding.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class OnboardingAPI extends ApiClient { + constructor() { + super('onboarding', { accountScoped: true }); + } + + update(data) { + return axios.patch(this.url, data); + } +} + +export default new OnboardingAPI(); diff --git a/app/javascript/dashboard/composables/useAccount.js b/app/javascript/dashboard/composables/useAccount.js index 5a8441d33..c6149044f 100644 --- a/app/javascript/dashboard/composables/useAccount.js +++ b/app/javascript/dashboard/composables/useAccount.js @@ -52,6 +52,10 @@ export function useAccount() { }); }; + const finishOnboarding = async data => { + await store.dispatch('accounts/finishOnboarding', data); + }; + return { accountId, route, @@ -61,5 +65,6 @@ export function useAccount() { isCloudFeatureEnabled, isOnChatwootCloud, updateAccount, + finishOnboarding, }; } diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue index 592672462..3a1d65bc6 100644 --- a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue @@ -28,7 +28,7 @@ import { const { t } = useI18n(); const router = useRouter(); const store = useStore(); -const { accountId, currentAccount, updateAccount } = useAccount(); +const { accountId, currentAccount, finishOnboarding } = useAccount(); const { enabledLanguages } = useConfig(); const currentUser = useMapGetter('getCurrentUser'); @@ -195,6 +195,12 @@ const handleWebsiteEnter = () => { websiteInput.value?.blur(); }; +const normalizeWebsiteUrl = raw => { + const trimmed = (raw || '').trim(); + if (!trimmed) return ''; + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; +}; + const handleSubmit = async () => { // Block submit while enrichment is still running so users can't bypass // the form with empty values — the controller would otherwise clear @@ -211,9 +217,27 @@ const handleSubmit = async () => { return; } + // Detect which enrichable fields the user actually edited *before* + // normalizing — otherwise an untouched auto-filled domain + // (acme.com -> https://acme.com) compares unequal against the raw snapshot + // and gets falsely reported as changed, skewing onboarding telemetry. + const init = initialValues.value; + const enrichableFields = { + website: website.value, + company_size: companySize.value, + industry: industry.value, + }; + const fieldsChanged = Object.entries(enrichableFields) + .filter(([key, val]) => val !== init[key]) + .map(([key]) => key); + + // Persist with a scheme so downstream consumers (Firecrawl, portal + // homepage_link) get a fully-qualified URL regardless of what the user typed. + website.value = normalizeWebsiteUrl(website.value); + isSubmitting.value = true; try { - await updateAccount({ + await finishOnboarding({ name: accountName.value, locale: locale.value, website: website.value, @@ -224,20 +248,11 @@ const handleSubmit = async () => { user_role: userRole.value, }); - const init = initialValues.value; - const enrichableFields = { - website: website.value, - company_size: companySize.value, - industry: industry.value, - }; - useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, { has_enriched_data: Boolean( currentAccount.value?.custom_attributes?.brand_info ), - fields_changed: Object.entries(enrichableFields) - .filter(([key, val]) => val !== init[key]) - .map(([key]) => key), + fields_changed: fieldsChanged, user_role: userRole.value, company_size: companySize.value, industry: industry.value, diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js index 52e7b8213..68fd37010 100644 --- a/app/javascript/dashboard/store/modules/accounts.js +++ b/app/javascript/dashboard/store/modules/accounts.js @@ -1,6 +1,7 @@ import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; import * as types from '../mutation-types'; import AccountAPI from '../../api/account'; +import OnboardingAPI from '../../api/onboarding'; import { differenceInDays } from 'date-fns'; import EnterpriseAccountAPI from '../../api/enterprise/account'; import { throwErrorMessage } from '../utils/api'; @@ -83,6 +84,15 @@ export const actions = { throw new Error(error); } }, + finishOnboarding: async ({ commit }, payload) => { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }); + try { + const response = await OnboardingAPI.update(payload); + commit(types.default.EDIT_ACCOUNT, response.data); + } finally { + commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }); + } + }, delete: async ({ commit }, { id }) => { commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }); try { diff --git a/config/routes.rb b/config/routes.rb index 004a940a3..aeb7fe525 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -54,6 +54,7 @@ Rails.application.routes.draw do resource :contact_merge, only: [:create] end resource :bulk_actions, only: [:create] + resource :onboarding, only: [:update] resources :agents, only: [:index, :create, :update, :destroy] do post :bulk_create, on: :collection end diff --git a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb new file mode 100644 index 000000000..ec2b4dcaa --- /dev/null +++ b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb @@ -0,0 +1,114 @@ +require 'rails_helper' + +RSpec.describe 'Onboarding API', type: :request do + let(:account) { create(:account, domain: 'example.com') } + let(:admin) { create(:user, account: account, role: :administrator) } + + describe 'PATCH /api/v1/accounts/{account.id}/onboarding' do + context 'when unauthenticated' do + it 'returns unauthorized' do + patch "/api/v1/accounts/#{account.id}/onboarding", params: { website: 'acme.com' }, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as an agent (non-admin)' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'returns unauthorized and does not change the account' do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { name: 'Hijacked', website: 'attacker.com' }, + headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unauthorized) + expect(account.reload.name).not_to eq('Hijacked') + end + + it 'does not create a help center portal' do + account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) + + expect do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'attacker.com' }, + headers: agent.create_new_auth_token, as: :json + end.not_to change(account.portals, :count) + end + end + + context 'when finalizing account_details' do + before { account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) } + + it 'saves name and locale' do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { name: 'Acme Inc', locale: 'fr' }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + expect(account.reload.name).to eq('Acme Inc') + expect(account.locale).to eq('fr') + end + + it 'merges custom_attributes' do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'acme.com', industry: 'tech', company_size: '10-50' }, + headers: admin.create_new_auth_token, as: :json + + attrs = account.reload.custom_attributes + expect(attrs['website']).to eq('acme.com') + expect(attrs['industry']).to eq('tech') + expect(attrs['company_size']).to eq('10-50') + end + + it 'clears onboarding_step' do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'acme.com' }, + headers: admin.create_new_auth_token, as: :json + + expect(account.reload.custom_attributes).not_to have_key('onboarding_step') + end + + it 'invokes HelpCenterCreationService when website is present', skip: 'help center generation wiring disabled until UI is ready' do + service = instance_double(Onboarding::HelpCenterCreationService, perform: nil) + allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service) + + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'acme.com' }, + headers: admin.create_new_auth_token, as: :json + + expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user| + expect(arg_account.id).to eq(account.id) + expect(arg_user.id).to eq(admin.id) + end + expect(service).to have_received(:perform) + end + + it 'does not create a help center portal when website is blank' do + expect do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { name: 'Acme Inc' }, + headers: admin.create_new_auth_token, as: :json + end.not_to change(account.portals, :count) + end + end + + context 'when onboarding_step is not account_details' do + before { account.update!(custom_attributes: { 'onboarding_step' => 'invite_team' }) } + + it 'does not clear onboarding_step' do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'acme.com' }, + headers: admin.create_new_auth_token, as: :json + + expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team') + end + + it 'does not create a help center portal' do + expect do + patch "/api/v1/accounts/#{account.id}/onboarding", + params: { website: 'acme.com' }, + headers: admin.create_new_auth_token, as: :json + end.not_to change(account.portals, :count) + end + end + end +end diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index 340803a9b..d93503418 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -302,16 +302,6 @@ RSpec.describe 'Accounts API', type: :request do expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team') end - it 'clears onboarding step when current value is account_details' do - account.update(custom_attributes: { onboarding_step: 'account_details' }) - patch "/api/v1/accounts/#{account.id}", - params: params, - headers: admin.create_new_auth_token, - as: :json - - expect(account.reload.custom_attributes).not_to have_key('onboarding_step') - end - it 'will not update onboarding step if onboarding step is not present in account custom attributes' do patch "/api/v1/accounts/#{account.id}", params: params, From 1afcd36deefe9a928281905e46c5dfffcd15f10d Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 1 Jun 2026 13:58:57 +0530 Subject: [PATCH 02/45] fix(contacts): align contact export permissions (#14601) Allows contact managers to export and import contacts from the Contacts page while keeping plain agents blocked. The contacts action menu now mirrors backend permissions for both export and import. ## Closes - https://linear.app/chatwoot/issue/CW-4438/contact-export-is-broken ## What changed - Allows Enterprise custom roles with `contact_manage` to pass `ContactPolicy#export?` and `ContactPolicy#import?`. - Shows Export and Import to admins and contact managers only. - Adds Enterprise policy coverage for contact export and import. ## Screenshots Admin: Export and Import are available. Admin contact actions with Export
and Import visible Contact manager: Export and Import are available. Contact manager contact actions
with Export and Import visible Regular agent: Export and Import are hidden. Regular agent contact actions with
Export and Import hidden ## How to test - Sign in as an administrator and open Contacts; the action menu shows Export and Import. - Sign in as a custom-role user with `contact_manage`; the action menu shows Export and Import. - Sign in as a plain agent; Export and Import are not available and both APIs remain unauthorized. --- .../components/ContactMoreActions.vue | 44 ++++++++++++------- app/policies/contact_policy.rb | 2 + .../app/policies/enterprise/contact_policy.rb | 9 ++++ .../policies/contact_policy_spec.rb | 26 +++++++++++ 4 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 enterprise/app/policies/enterprise/contact_policy.rb create mode 100644 spec/enterprise/policies/contact_policy_spec.rb diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue index d5932535c..9deaa84f2 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue @@ -1,34 +1,48 @@ + + diff --git a/spec/builders/messages/facebook/message_builder_spec.rb b/spec/builders/messages/facebook/message_builder_spec.rb index f3244bc21..afa9d5f34 100644 --- a/spec/builders/messages/facebook/message_builder_spec.rb +++ b/spec/builders/messages/facebook/message_builder_spec.rb @@ -140,6 +140,45 @@ describe Messages::Facebook::MessageBuilder do end end + [ + { + source_id: 'm_fallback_test', + attachment: { type: 'fallback', title: 'Shared link', url: 'https://www.example.com/shared-link' }, + title: 'Shared link', + url: 'https://www.example.com/shared-link' + }, + { + source_id: 'm_share_test', + attachment: { type: 'share', title: 'Shared Facebook post', payload: { url: 'https://www.facebook.com/example/posts/123' } }, + title: 'Shared Facebook post', + url: 'https://www.facebook.com/example/posts/123' + } + ].each do |message_data| + it "stores #{message_data[:attachment][:type]} attachments as fallback links" do + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) + allow(fb_object).to receive(:get_object).and_return( + { first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access + ) + expect(Down).not_to receive(:download) + + message_object = { + messaging: { + sender: { id: '3383290475046708' }, + recipient: { id: facebook_channel.page_id }, + message: { mid: message_data[:source_id], attachments: [message_data[:attachment]] } + } + }.to_json + message = Integrations::Facebook::MessageParser.new(message_object) + + described_class.new(message, facebook_channel.inbox).perform + + attachment = facebook_channel.inbox.messages.find_by(source_id: message_data[:source_id]).attachments.first + expect(attachment.file_type).to eq('fallback') + expect(attachment.fallback_title).to eq(message_data[:title]) + expect(attachment.external_url).to eq(message_data[:url]) + end + end + context 'when lock to single conversation' do subject(:mocked_message_builder) do described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform From 1f6203d5584304daecd92152b689b06103df80c0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 2 Jun 2026 13:24:46 +0530 Subject: [PATCH 07/45] feat(onboarding): honor return_to hint in TikTok OAuth callback (#14569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tiktok/authorizations_controller.rb | 2 +- .../tiktok/callbacks_controller.rb | 6 +++++ app/helpers/tiktok/integration_helper.rb | 25 ++++++++++++------- config/routes.rb | 1 + 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb index 7c7320393..64bf38775 100644 --- a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O def create redirect_url = Tiktok::AuthClient.authorize_url( - state: generate_tiktok_token(Current.account.id) + state: generate_tiktok_token(Current.account.id, params[:return_to]) ) if redirect_url diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb index e484905c3..20c0ee9c0 100644 --- a/app/controllers/tiktok/callbacks_controller.rb +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -20,6 +20,8 @@ class Tiktok::CallbacksController < ApplicationController def process_successful_authorization inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding' + if already_exists redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) else @@ -127,6 +129,10 @@ class Tiktok::CallbacksController < ApplicationController @account_id ||= verify_tiktok_token(params[:state]) end + def return_to + tiktok_token_return_to(params[:state]) + end + def account @account ||= Account.find(account_id) end diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb index b2de4a092..7bc8bc4ab 100644 --- a/app/helpers/tiktok/integration_helper.rb +++ b/app/helpers/tiktok/integration_helper.rb @@ -2,11 +2,12 @@ module Tiktok::IntegrationHelper # Generates a signed JWT token for Tiktok integration # # @param account_id [Integer] The account ID to encode in the token + # @param return_to [String, nil] Optional onboarding return hint # @return [String, nil] The encoded JWT token or nil if client secret is missing - def generate_tiktok_token(account_id) + def generate_tiktok_token(account_id, return_to = nil) return if client_secret.blank? - JWT.encode(token_payload(account_id), client_secret, 'HS256') + JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256') rescue StandardError => e Rails.logger.error("Failed to generate TikTok token: #{e.message}") nil @@ -19,7 +20,14 @@ module Tiktok::IntegrationHelper def verify_tiktok_token(token) return if token.blank? || client_secret.blank? - decode_token(token, client_secret) + decode_token(token, client_secret)&.dig('sub') + end + + # Reads the onboarding return hint from a Tiktok JWT token, if present. + def tiktok_token_return_to(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret)&.dig('return_to') end private @@ -28,18 +36,17 @@ module Tiktok::IntegrationHelper @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil) end - def token_payload(account_id) - { - sub: account_id, - iat: Time.current.to_i - } + def token_payload(account_id, return_to = nil) + payload = { sub: account_id, iat: Time.current.to_i } + payload[:return_to] = return_to if return_to.present? + payload end def decode_token(token, secret) JWT.decode(token, secret, true, { algorithm: 'HS256', verify_expiration: true - }).first['sub'] + }).first rescue StandardError => e Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}") nil diff --git a/config/routes.rb b/config/routes.rb index aeb7fe525..15c471891 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,6 +29,7 @@ Rails.application.routes.draw do get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings' get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_tiktok_inbox_settings' get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings' + get '/app/accounts/:account_id/onboarding/inbox-setup', to: 'dashboard#index', as: 'app_onboarding_inbox_setup' resource :widget, only: [:show] namespace :survey do From 04ac9d378054eaa8ab0610f379b4983151544bad Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 2 Jun 2026 13:26:30 +0530 Subject: [PATCH 08/45] fix: use UPN for `imap_login` on Microsoft OAuth callback (#14522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: `, `preferred_username: `) 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=` returned `535 5.7.3`, `user=` 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. --- .../microsoft/callbacks_controller.rb | 7 +++++++ app/controllers/oauth_callback_controller.rb | 9 ++++++++- .../microsoft/callbacks_controller_spec.rb | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb index 2f07505fc..045789f75 100644 --- a/app/controllers/microsoft/callbacks_controller.rb +++ b/app/controllers/microsoft/callbacks_controller.rb @@ -14,4 +14,11 @@ class Microsoft::CallbacksController < OauthCallbackController def imap_address 'outlook.office365.com' end + + # Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field; + # it must match the token's UPN. `preferred_username` is the documented v2.0 claim; + # `upn` is the v1.0 fallback. + def imap_login_identity + users_data['preferred_username'] || users_data['upn'] || super + end end diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index be0fa5008..8929db987 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -44,7 +44,7 @@ class OauthCallbackController < ApplicationController def update_channel(channel_email) channel_email.update!({ - imap_login: users_data['email'], imap_address: imap_address, + imap_login: imap_login_identity, imap_address: imap_address, imap_port: '993', imap_enabled: true, provider: provider_name, provider_config: { @@ -55,6 +55,13 @@ class OauthCallbackController < ApplicationController }) end + # Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the + # id_token's email claim; providers override when their server requires a different + # claim (e.g. Microsoft SMTP requires UPN). + def imap_login_identity + users_data['email'] + end + def provider_name raise NotImplementedError end diff --git a/spec/controllers/microsoft/callbacks_controller_spec.rb b/spec/controllers/microsoft/callbacks_controller_spec.rb index 129cfa383..be13d5c84 100644 --- a/spec/controllers/microsoft/callbacks_controller_spec.rb +++ b/spec/controllers/microsoft/callbacks_controller_spec.rb @@ -34,6 +34,25 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do expect(inbox.channel.imap_address).to eq 'outlook.office365.com' end + it 'sets imap_login from preferred_username when the id_token carries a UPN that differs from email' do + upn = 'testaccount@primary-domain.example' + mailbox = 'TestAccount@mailbox-domain.example' + response_body = { + id_token: JWT.encode({ email: mailbox, preferred_username: upn, name: 'test' }, nil, 'none'), + access_token: SecureRandom.hex(10), token_type: 'Bearer', refresh_token: SecureRandom.hex(10) + } + stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token') + .with(body: { 'code' => code, 'grant_type' => 'authorization_code', + 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" }) + .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + + get microsoft_callback_url, params: { code: code, state: state } + + channel = account.inboxes.last.channel + expect(channel.imap_login).to eq upn + expect(channel.email).to eq mailbox + end + it 'creates updates inbox channel config if inbox exists and authentication is successful' do inbox = create(:channel_email, account: account, email: email)&.inbox expect(inbox.channel.provider_config).to eq({}) From 4c6a345d604888920e45e0ba13c937705135f8d0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 2 Jun 2026 14:21:11 +0530 Subject: [PATCH 09/45] 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 --- .../accounts/oauth_authorization_controller.rb | 10 +++++++++- app/controllers/oauth_callback_controller.rb | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb index feb218b59..7fbca86da 100644 --- a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb +++ b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb @@ -8,7 +8,15 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC end def state - Current.account.to_sgid(expires_in: 15.minutes).to_s + # The sgid purpose doubles as a return hint: onboarding tags it so the callback + # can route the user back to inbox setup. The purpose is part of the signed + # payload (tamper-proof), and a non-onboarding request keeps the default + # purpose, leaving callers like Notion byte-identical. + Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s + end + + def state_purpose + params[:return_to] == 'onboarding' ? 'onboarding' : 'default' end def base_url diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index 8929db987..4a3d049a6 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -16,6 +16,8 @@ class OauthCallbackController < ApplicationController def handle_response inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding' + if already_exists redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) else @@ -88,10 +90,19 @@ class OauthCallbackController < ApplicationController decoded_token[0] end + # The sgid purpose carries the onboarding return hint (see + # OauthAuthorizationController#state). Try the onboarding purpose first — a match + # both resolves the account and records the return target — then fall back to the + # default purpose used by every other caller. def account_from_signed_id raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? - account = GlobalID::Locator.locate_signed(params[:state]) + if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding')) + @return_to = 'onboarding' + else + account = GlobalID::Locator.locate_signed(params[:state]) + end + raise 'Invalid or expired state' if account.nil? account @@ -101,6 +112,11 @@ class OauthCallbackController < ApplicationController @account ||= account_from_signed_id end + def return_to + account # resolving the sgid records which purpose matched + @return_to + end + # Fallback name, for when name field is missing from users_data def fallback_name users_data['email'].split('@').first.parameterize.titleize From 33dea837168b145c357cace9f554531990c4664b Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 2 Jun 2026 14:33:02 +0530 Subject: [PATCH 10/45] docs: document message attachment uploads (#14600) Updates the Create New Message API documentation to explain how to send file attachments with multipart form data. Closes #10472 Closes #12672 ## Why The endpoint already accepts attachment uploads, but the published API docs only described the JSON request body. That made it unclear that clients need to use multipart form data and send files through the `attachments[]` field. ## What changed - Adds `multipart/form-data` as a documented request body option for Create New Message. - Documents the `attachments` binary array and form encoding used by `attachments[]`. - Adds a multipart cURL request example for a message with an attachment. - Regenerates the Swagger JSON artifacts. ## Screenshots Create New Message API docs with the multipart cURL sample and `multipart/form-data` request sample selected: create-message-attachment-docs-multipart ## How to test 1. Open `/swagger`. 2. Navigate to Application -> Messages -> Create New Message. 3. Confirm the endpoint documents both `application/json` and `multipart/form-data`, including the attachment payload schema. --- .../conversation/messages/create.yml | 69 ++++++++++++++++ swagger/swagger.json | 82 ++++++++++++++++++- swagger/tag_groups/application_swagger.json | 82 ++++++++++++++++++- 3 files changed, 231 insertions(+), 2 deletions(-) diff --git a/swagger/paths/application/conversation/messages/create.yml b/swagger/paths/application/conversation/messages/create.yml index 1b8272585..09dece97f 100644 --- a/swagger/paths/application/conversation/messages/create.yml +++ b/swagger/paths/application/conversation/messages/create.yml @@ -4,6 +4,23 @@ operationId: create-a-new-message-in-a-conversation summary: Create New Message description: | Create a new message in the conversation. + + Use `application/json` for text messages and `multipart/form-data` when the + message includes file attachments. + + ### Multipart attachment request + + Send files with the `attachments[]` form field. `curl -F` sets the + `multipart/form-data` content type and boundary automatically. + + ```bash + curl -X POST "https://app.chatwoot.com/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages" \ + -H "api_access_token: " \ + -F "content=Here is the screenshot" \ + -F "message_type=outgoing" \ + -F "private=false" \ + -F "attachments[]=@/path/to/screenshot.png" + ``` ## WhatsApp Template Messages @@ -62,6 +79,58 @@ requestBody: application/json: schema: $ref: '#/components/schemas/conversation_message_create_payload' + multipart/form-data: + schema: + type: object + description: Form data payload for creating a message with file attachments. + example: + content: Here is the screenshot + message_type: outgoing + private: false + 'attachments[]': + - screenshot.png + properties: + content: + type: string + description: The content of the message + example: Here is the screenshot + message_type: + type: string + enum: ['outgoing', 'incoming'] + description: The type of the message + example: outgoing + private: + type: boolean + description: Flag to identify if it is a private note + example: false + content_type: + type: string + enum: ['text', 'input_email', 'cards', 'input_select', 'form', 'article'] + description: Content type of the message + example: text + content_attributes: + type: object + description: Attributes based on the content type + example: {} + 'attachments[]': + type: array + description: Files to attach to the message + items: + type: string + format: binary + encoding: + 'attachments[]': + style: form + explode: true + examples: + attachment_message: + summary: Message with an attachment + value: + content: Here is the screenshot + message_type: outgoing + private: false + 'attachments[]': + - screenshot.png responses: '200': description: Success diff --git a/swagger/swagger.json b/swagger/swagger.json index a73b3ba95..b21742b4e 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -6737,7 +6737,7 @@ ], "operationId": "create-a-new-message-in-a-conversation", "summary": "Create New Message", - "description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n", + "description": "Create a new message in the conversation.\n\nUse `application/json` for text messages and `multipart/form-data` when the\nmessage includes file attachments.\n\n### Multipart attachment request\n\nSend files with the `attachments[]` form field. `curl -F` sets the\n`multipart/form-data` content type and boundary automatically.\n\n```bash\ncurl -X POST \"https://app.chatwoot.com/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages\" \\\n -H \"api_access_token: \" \\\n -F \"content=Here is the screenshot\" \\\n -F \"message_type=outgoing\" \\\n -F \"private=false\" \\\n -F \"attachments[]=@/path/to/screenshot.png\"\n```\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n", "security": [ { "userApiKey": [] @@ -6753,6 +6753,86 @@ "schema": { "$ref": "#/components/schemas/conversation_message_create_payload" } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "description": "Form data payload for creating a message with file attachments.", + "example": { + "content": "Here is the screenshot", + "message_type": "outgoing", + "private": false, + "attachments[]": [ + "screenshot.png" + ] + }, + "properties": { + "content": { + "type": "string", + "description": "The content of the message", + "example": "Here is the screenshot" + }, + "message_type": { + "type": "string", + "enum": [ + "outgoing", + "incoming" + ], + "description": "The type of the message", + "example": "outgoing" + }, + "private": { + "type": "boolean", + "description": "Flag to identify if it is a private note", + "example": false + }, + "content_type": { + "type": "string", + "enum": [ + "text", + "input_email", + "cards", + "input_select", + "form", + "article" + ], + "description": "Content type of the message", + "example": "text" + }, + "content_attributes": { + "type": "object", + "description": "Attributes based on the content type", + "example": {} + }, + "attachments[]": { + "type": "array", + "description": "Files to attach to the message", + "items": { + "type": "string", + "format": "binary" + } + } + } + }, + "encoding": { + "attachments[]": { + "style": "form", + "explode": true + } + }, + "examples": { + "attachment_message": { + "summary": "Message with an attachment", + "value": { + "content": "Here is the screenshot", + "message_type": "outgoing", + "private": false, + "attachments[]": [ + "screenshot.png" + ] + } + } + } } } }, diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index 4b1977577..f9ff31a7e 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -5280,7 +5280,7 @@ ], "operationId": "create-a-new-message-in-a-conversation", "summary": "Create New Message", - "description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n", + "description": "Create a new message in the conversation.\n\nUse `application/json` for text messages and `multipart/form-data` when the\nmessage includes file attachments.\n\n### Multipart attachment request\n\nSend files with the `attachments[]` form field. `curl -F` sets the\n`multipart/form-data` content type and boundary automatically.\n\n```bash\ncurl -X POST \"https://app.chatwoot.com/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages\" \\\n -H \"api_access_token: \" \\\n -F \"content=Here is the screenshot\" \\\n -F \"message_type=outgoing\" \\\n -F \"private=false\" \\\n -F \"attachments[]=@/path/to/screenshot.png\"\n```\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n", "security": [ { "userApiKey": [] @@ -5296,6 +5296,86 @@ "schema": { "$ref": "#/components/schemas/conversation_message_create_payload" } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "description": "Form data payload for creating a message with file attachments.", + "example": { + "content": "Here is the screenshot", + "message_type": "outgoing", + "private": false, + "attachments[]": [ + "screenshot.png" + ] + }, + "properties": { + "content": { + "type": "string", + "description": "The content of the message", + "example": "Here is the screenshot" + }, + "message_type": { + "type": "string", + "enum": [ + "outgoing", + "incoming" + ], + "description": "The type of the message", + "example": "outgoing" + }, + "private": { + "type": "boolean", + "description": "Flag to identify if it is a private note", + "example": false + }, + "content_type": { + "type": "string", + "enum": [ + "text", + "input_email", + "cards", + "input_select", + "form", + "article" + ], + "description": "Content type of the message", + "example": "text" + }, + "content_attributes": { + "type": "object", + "description": "Attributes based on the content type", + "example": {} + }, + "attachments[]": { + "type": "array", + "description": "Files to attach to the message", + "items": { + "type": "string", + "format": "binary" + } + } + } + }, + "encoding": { + "attachments[]": { + "style": "form", + "explode": true + } + }, + "examples": { + "attachment_message": { + "summary": "Message with an attachment", + "value": { + "content": "Here is the screenshot", + "message_type": "outgoing", + "private": false, + "attachments[]": [ + "screenshot.png" + ] + } + } + } } } }, From 88e2661ca600a44197ffb08193b58e6fb33e819b Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Tue, 2 Jun 2026 14:35:37 +0530 Subject: [PATCH 11/45] 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 --- .../conversations/unread_counts_controller.rb | 10 ---- .../components-next/sidebar/Sidebar.vue | 16 +---- app/javascript/dashboard/featureFlags.js | 1 - .../dashboard/helper/actionCable.js | 14 ----- .../helper/specs/actionCable.spec.js | 16 ----- .../store/modules/conversationUnreadCounts.js | 3 - .../conversationUnreadCounts/actions.spec.js | 11 ---- app/listeners/action_cable_listener.rb | 2 +- app/models/account.rb | 1 - .../conversations/unread_counts/listener.rb | 3 +- .../conversations/unread_counts/notifier.rb | 2 - config/features.yml | 3 +- config/locales/en.yml | 3 - .../accounts/conversations_controller_spec.rb | 59 +++++++------------ spec/listeners/action_cable_listener_spec.rb | 12 ---- spec/models/account_spec.rb | 38 ------------ .../unread_counts/listener_spec.rb | 13 ---- .../unread_counts/notifier_spec.rb | 16 ----- 18 files changed, 27 insertions(+), 196 deletions(-) diff --git a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb index d9f15613b..6b1ee90e6 100644 --- a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb @@ -1,16 +1,6 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController - before_action :ensure_unread_counts_enabled - def index counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform render json: { payload: counts } end - - private - - def ensure_unread_counts_enabled - return if Current.account.feature_enabled?('conversation_unread_counts') - - render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden - end end diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index d9f523dfc..0fccfbee5 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -61,21 +61,9 @@ const hasAdvancedAssignment = computed(() => { ); }); -const hasConversationUnreadCounts = computed(() => { - return isFeatureEnabledonAccount.value( - accountId.value, - FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS - ); -}); - -const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => { +const fetchConversationUnreadCounts = currentAccountId => { if (!currentAccountId) return; - if (!isEnabled) { - store.dispatch('conversationUnreadCounts/clear'); - return; - } - store.dispatch('conversationUnreadCounts/get'); }; @@ -200,7 +188,7 @@ onMounted(() => { store.dispatch('customViews/get', 'contact'); }); -watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, { +watch(accountId, fetchConversationUnreadCounts, { immediate: true, }); diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 00a79763b..b97e30984 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -46,7 +46,6 @@ export const FEATURE_FLAGS = { COMPANIES: 'companies', ADVANCED_SEARCH: 'advanced_search', CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes', - CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts', }; export const PREMIUM_FEATURES = [ diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index e6f2993e4..a2caab5f5 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -13,7 +13,6 @@ import { } from 'dashboard/composables/useWhatsappCallSession'; import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox'; import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants'; -import { FEATURE_FLAGS } from 'dashboard/featureFlags'; const { isImpersonating } = useImpersonation(); const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000; @@ -172,23 +171,10 @@ class ActionCableConnector extends BaseActionCableConnector { }; fetchConversationUnreadCounts = () => { - if (!this.isConversationUnreadCountsEnabled()) return; - this.lastUnreadCountsFetchAt = Date.now(); this.app.$store.dispatch('conversationUnreadCounts/get'); }; - isConversationUnreadCountsEnabled = () => { - const accountId = this.app.$store.getters.getCurrentAccountId; - const isFeatureEnabled = - this.app.$store.getters['accounts/isFeatureEnabledonAccount']; - - return isFeatureEnabled?.( - accountId, - FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS - ); - }; - onTypingOn = ({ conversation, user }) => { const conversationId = conversation.id; diff --git a/app/javascript/dashboard/helper/specs/actionCable.spec.js b/app/javascript/dashboard/helper/specs/actionCable.spec.js index 8ba411a5f..2085b415a 100644 --- a/app/javascript/dashboard/helper/specs/actionCable.spec.js +++ b/app/javascript/dashboard/helper/specs/actionCable.spec.js @@ -30,7 +30,6 @@ describe('ActionCableConnector - Copilot Tests', () => { dispatch: mockDispatch, getters: { getCurrentAccountId: 1, - 'accounts/isFeatureEnabledonAccount': vi.fn(() => true), }, }, }; @@ -89,21 +88,6 @@ describe('ActionCableConnector - Copilot Tests', () => { expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get'); }); - it('does not refetch unread counts when unread count feature is disabled', () => { - store.$store.getters[ - 'accounts/isFeatureEnabledonAccount' - ].mockReturnValue(false); - - actionCable.onReceived({ - event: 'conversation.unread_count_changed', - data: { account_id: 1 }, - }); - - expect(mockDispatch).not.toHaveBeenCalledWith( - 'conversationUnreadCounts/get' - ); - }); - it('should throttle unread count refetches for repeated events', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js index 0503c0806..edffed4fd 100644 --- a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js +++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js @@ -48,9 +48,6 @@ export const actions = { // Ignore errors so the sidebar can continue rendering without badges. } }, - clear({ commit }) { - commit(types.SET_CONVERSATION_UNREAD_COUNTS, {}); - }, }; export const mutations = { diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js index 3100cdd10..0974689d5 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js @@ -39,15 +39,4 @@ describe('#actions', () => { expect(commit).not.toHaveBeenCalled(); }); }); - - describe('#clear', () => { - it('clears unread counts', () => { - actions.clear({ commit }); - - expect(commit).toHaveBeenCalledWith( - types.SET_CONVERSATION_UNREAD_COUNTS, - {} - ); - }); - }); }); diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb index 3bc221504..86fba29a0 100644 --- a/app/listeners/action_cable_listener.rb +++ b/app/listeners/action_cable_listener.rb @@ -92,7 +92,7 @@ class ActionCableListener < BaseListener def conversation_unread_count_changed(event) account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform - return if account.blank? || !account.feature_enabled?('conversation_unread_counts') + return if account.blank? tokens = user_tokens(account, inbox_members) diff --git a/app/models/account.rb b/app/models/account.rb index 667058a2f..8d66acca8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -109,7 +109,6 @@ class Account < ApplicationRecord before_validation :validate_limit_keys after_create_commit :notify_creation - after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts? after_destroy :remove_account_sequences def agents diff --git a/app/services/conversations/unread_counts/listener.rb b/app/services/conversations/unread_counts/listener.rb index 28792d884..729613639 100644 --- a/app/services/conversations/unread_counts/listener.rb +++ b/app/services/conversations/unread_counts/listener.rb @@ -4,7 +4,6 @@ class Conversations::UnreadCounts::Listener < BaseListener def message_created(event) message, = extract_message_and_account(event) return unless message.incoming? - return unless message.account.feature_enabled?('conversation_unread_counts') refresh(message.conversation) end @@ -36,7 +35,7 @@ class Conversations::UnreadCounts::Listener < BaseListener return if conversation_data.blank? account = Account.find_by(id: conversation_data[:account_id]) - return unless account&.feature_enabled?('conversation_unread_counts') + return if account.blank? return unless remove_deleted_conversation(account, conversation_data) Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h) diff --git a/app/services/conversations/unread_counts/notifier.rb b/app/services/conversations/unread_counts/notifier.rb index 652fbde3a..461945f2c 100644 --- a/app/services/conversations/unread_counts/notifier.rb +++ b/app/services/conversations/unread_counts/notifier.rb @@ -9,8 +9,6 @@ class Conversations::UnreadCounts::Notifier end def perform - return false unless conversation.account.feature_enabled?('conversation_unread_counts') - return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation) diff --git a/config/features.yml b/config/features.yml index 03105588b..8444b7379 100644 --- a/config/features.yml +++ b/config/features.yml @@ -19,8 +19,9 @@ help_url: https://chwt.app/hc/fb - name: conversation_unread_counts display_name: Conversation Unread Counts - enabled: false + enabled: true chatwoot_internal: true + deprecated: true - name: ip_lookup display_name: IP Lookup enabled: false diff --git a/config/locales/en.yml b/config/locales/en.yml index b11a4fdae..ed52f27d8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -81,9 +81,6 @@ en: saml: feature_not_enabled: SAML feature not enabled for this account sso_not_enabled: SAML SSO is not enabled for this installation - conversations: - unread_counts: - feature_not_enabled: Conversation unread counts feature not enabled for this account data_import: data_type: invalid: Invalid data type diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index f8fd446d2..39bdb398c 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -126,47 +126,32 @@ RSpec.describe 'Conversations API', type: :request do Conversations::UnreadCounts::Store.clear_account!(account.id) end - context 'when conversation unread counts feature is enabled' do - before do - account.enable_features!(:conversation_unread_counts) - end + it 'returns unread conversation counts scoped to the signed-in user' do + create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title]) + create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title]) - it 'returns unread conversation counts scoped to the signed-in user' do - create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title]) - create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title]) - - get "/api/v1/accounts/#{account.id}/conversations/unread_counts", - headers: agent.create_new_auth_token, - as: :json - - expect(response).to have_http_status(:success) - expect(response.parsed_body['payload']).to eq( - 'inboxes' => { visible_inbox.id.to_s => 1 }, - 'labels' => { label.id.to_s => 1 }, - 'teams' => {} - ) - end - - it 'returns unread team conversation counts scoped to the signed-in user' do - create_unread_conversation(account: account, inbox: visible_inbox, team: team) - create_unread_conversation(account: account, inbox: hidden_inbox, team: team) - - get "/api/v1/accounts/#{account.id}/conversations/unread_counts", - headers: agent.create_new_auth_token, - as: :json - - expect(response).to have_http_status(:success) - expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1) - end - end - - it 'returns forbidden when conversation unread counts feature is disabled' do get "/api/v1/accounts/#{account.id}/conversations/unread_counts", headers: agent.create_new_auth_token, as: :json - expect(response).to have_http_status(:forbidden) - expect(response.parsed_body['error']).to eq('Conversation unread counts feature not enabled for this account') + expect(response).to have_http_status(:success) + expect(response.parsed_body['payload']).to eq( + 'inboxes' => { visible_inbox.id.to_s => 1 }, + 'labels' => { label.id.to_s => 1 }, + 'teams' => {} + ) + end + + it 'returns unread team conversation counts scoped to the signed-in user' do + create_unread_conversation(account: account, inbox: visible_inbox, team: team) + create_unread_conversation(account: account, inbox: hidden_inbox, team: team) + + get "/api/v1/accounts/#{account.id}/conversations/unread_counts", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1) end end end @@ -848,7 +833,6 @@ RSpec.describe 'Conversations API', type: :request do end it 'refreshes unread count cache when conversation is marked read' do - account.enable_features!(:conversation_unread_counts) conversation.update!(agent_last_seen_at: 1.hour.ago) create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago) Conversations::UnreadCounts::Builder.new(account).build_base! @@ -936,7 +920,6 @@ RSpec.describe 'Conversations API', type: :request do end it 'refreshes unread count cache when conversation is marked unread' do - account.enable_features!(:conversation_unread_counts) conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now) Conversations::UnreadCounts::Builder.new(account).build_base! diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb index cdb9a93cb..f8d50060f 100644 --- a/spec/listeners/action_cable_listener_spec.rb +++ b/spec/listeners/action_cable_listener_spec.rb @@ -237,10 +237,6 @@ describe ActionCableListener do let!(:agent_without_inbox_access) { create(:user, account: account, role: :agent) } let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) } - before do - account.enable_features!(:conversation_unread_counts) - end - it 'sends a lightweight refresh event to inbox agents and admins' do expect(conversation.inbox.reload.inbox_members.count).to eq(1) @@ -265,14 +261,6 @@ describe ActionCableListener do listener.conversation_unread_count_changed(event) end - it 'does not broadcast when conversation unread counts feature is disabled' do - account.disable_features!(:conversation_unread_counts) - - expect(ActionCableBroadcastJob).not_to receive(:perform_later) - - listener.conversation_unread_count_changed(event) - end - it 'supports deleted conversation data' do event = Events::Base.new( event_name, diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 38ca9694a..76dbbcba2 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -50,44 +50,6 @@ RSpec.describe Account do end end - describe 'conversation unread counts feature flag' do - let(:account) { create(:account) } - let(:inbox) { create(:inbox, account: account) } - let(:store) { Conversations::UnreadCounts::Store } - let(:inbox_key) { store.inbox_key(account.id, inbox.id) } - - after do - store.clear_account!(account.id) - end - - it 'clears unread count cache when the feature is enabled' do - build_unread_count_cache - - account.enable_features!(:conversation_unread_counts) - - expect(store.base_ready?(account.id)).to be(false) - expect(store.assignment_ready?(account.id)).to be(false) - expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0) - end - - it 'clears unread count cache when the feature is disabled' do - account.enable_features!(:conversation_unread_counts) - build_unread_count_cache - - account.disable_features!(:conversation_unread_counts) - - expect(store.base_ready?(account.id)).to be(false) - expect(store.assignment_ready?(account.id)).to be(false) - expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0) - end - - def build_unread_count_cache - store.mark_base_ready!(account.id) - store.mark_assignment_ready!(account.id) - store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1) - end - end - describe 'inbound_email_domain' do let(:account) { create(:account) } diff --git a/spec/services/conversations/unread_counts/listener_spec.rb b/spec/services/conversations/unread_counts/listener_spec.rb index fbb0a0835..a8a400365 100644 --- a/spec/services/conversations/unread_counts/listener_spec.rb +++ b/spec/services/conversations/unread_counts/listener_spec.rb @@ -11,7 +11,6 @@ RSpec.describe Conversations::UnreadCounts::Listener do end it 'refreshes unread counts when an incoming message is created' do - account.enable_features!(:conversation_unread_counts) message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming) event = Events::Base.new('message.created', Time.zone.now, message: message) @@ -30,17 +29,6 @@ RSpec.describe Conversations::UnreadCounts::Listener do expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new) end - it 'ignores incoming message creation when conversation unread counts are disabled' do - message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming) - event = Events::Base.new('message.created', Time.zone.now, message: message) - - expect(message).not_to receive(:conversation) - - listener.message_created(event) - - expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new) - end - it 'refreshes unread counts when conversation status changes' do changed_attributes = { 'status' => %w[open resolved] } event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) @@ -90,7 +78,6 @@ RSpec.describe Conversations::UnreadCounts::Listener do end it 'removes unread count memberships when a conversation is deleted' do - account.enable_features!(:conversation_unread_counts) label = create(:label, account: account) team = create(:team, account: account) assignee = create(:user, account: account) diff --git a/spec/services/conversations/unread_counts/notifier_spec.rb b/spec/services/conversations/unread_counts/notifier_spec.rb index 1b35d37f6..6c6028548 100644 --- a/spec/services/conversations/unread_counts/notifier_spec.rb +++ b/spec/services/conversations/unread_counts/notifier_spec.rb @@ -6,7 +6,6 @@ RSpec.describe Conversations::UnreadCounts::Notifier do let(:refresh_result) { true } before do - conversation.account.enable_features!(:conversation_unread_counts) allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(refresher) allow(Rails.configuration.dispatcher).to receive(:dispatch) end @@ -30,19 +29,4 @@ RSpec.describe Conversations::UnreadCounts::Notifier do expect(Rails.configuration.dispatcher).not_to have_received(:dispatch) end end - - context 'when conversation unread counts feature is disabled' do - before do - conversation.account.disable_features!(:conversation_unread_counts) - allow(Conversations::UnreadCounts::Store).to receive(:clear_account!) - end - - it 'does not refresh, clear cache, or dispatch unread count changed event' do - described_class.new(conversation).perform - - expect(Conversations::UnreadCounts::Refresher).not_to have_received(:new) - expect(Conversations::UnreadCounts::Store).not_to have_received(:clear_account!) - expect(Rails.configuration.dispatcher).not_to have_received(:dispatch) - end - end end From ecd9c26c8cd2c334fa7e08e61ee77cb5252417d4 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 2 Jun 2026 11:49:23 +0200 Subject: [PATCH 12/45] 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: classic-results Classic layout pagination: classic-page-two Classic layout empty search: no-results Documentation layout search results: documentation-results Documentation layout dark theme: documentation-dark Plain embedded dark layout: plain-embedded-dark --------- Co-authored-by: Shivam Mishra Co-authored-by: Sojan Jose Co-authored-by: Pranav Co-authored-by: Muhsin Keloth Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com> --- .../api/v1/portals/search_controller.rb | 31 +++++ app/helpers/portal_helper.rb | 13 +- app/javascript/entrypoints/portal.js | 6 +- app/javascript/portal/application.scss | 2 +- .../portal/components/PublicArticleSearch.vue | 37 ++++-- .../portal/components/TableOfContents.vue | 4 +- app/javascript/portal/portalHelpers.js | 5 +- app/models/article.rb | 4 + app/views/layouts/_portal_head.html.erb | 2 +- app/views/layouts/_portal_scripts.html.erb | 1 + .../documentation_layout/_hero.html.erb | 4 +- .../documentation_layout/_topbar.html.erb | 2 +- .../api/v1/portals/search/_form.html.erb | 20 +++ .../portals/search/_search_handler.html.erb | 33 +++++ .../search/index.html+documentation.erb | 64 ++++++++++ .../api/v1/portals/search/index.html.erb | 118 ++++++++++++++++++ config/locales/en.yml | 9 ++ config/routes.rb | 1 + .../api/v1/portals/search_controller.rb | 9 ++ .../app/models/enterprise/concerns/article.rb | 12 +- package.json | 2 +- pnpm-lock.yaml | 30 +++-- spec/helpers/portal_helper_spec.rb | 12 +- spec/models/article_spec.rb | 9 ++ 24 files changed, 385 insertions(+), 45 deletions(-) create mode 100644 app/controllers/public/api/v1/portals/search_controller.rb create mode 100644 app/views/public/api/v1/portals/search/_form.html.erb create mode 100644 app/views/public/api/v1/portals/search/_search_handler.html.erb create mode 100644 app/views/public/api/v1/portals/search/index.html+documentation.erb create mode 100644 app/views/public/api/v1/portals/search/index.html.erb create mode 100644 enterprise/app/controllers/enterprise/public/api/v1/portals/search_controller.rb diff --git a/app/controllers/public/api/v1/portals/search_controller.rb b/app/controllers/public/api/v1/portals/search_controller.rb new file mode 100644 index 000000000..104741773 --- /dev/null +++ b/app/controllers/public/api/v1/portals/search_controller.rb @@ -0,0 +1,31 @@ +class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController + before_action :ensure_custom_domain_request, only: [:index] + before_action :portal + before_action :set_portal_layout + before_action :set_view_variant + before_action :ensure_portal_feature_enabled + layout 'portal' + + def index + @query = params[:query].to_s.strip + @articles = @portal.articles.published.includes(:category).where(locale: params[:locale]) + + search_articles + + @articles = @articles.page(params[:page]).per(10) + end + + private + + def search_articles + @articles = @query.present? ? @articles.search(search_params) : @articles.none + end + + def search_params + params.permit(:query, :locale, :sort, :status, :page).tap do |permitted| + permitted[:query] = @query + end + end +end + +Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController') diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 65166145c..0c993ec59 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -45,9 +45,16 @@ module PortalHelper theme.present? && theme != 'system' ? "?theme=#{theme}" : '' end + def portal_query_string(theme, is_plain_layout_enabled) + query_params = {} + query_params[:theme] = theme if theme.present? && theme != 'system' + query_params[:show_plain_layout] = true if is_plain_layout_enabled + query_params.present? ? "?#{query_params.to_query}" : '' + end + def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled) if is_plain_layout_enabled - "/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/#{portal_locale}" end @@ -61,7 +68,7 @@ module PortalHelper is_plain_layout_enabled = params[:is_plain_layout_enabled] if is_plain_layout_enabled - "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}" end @@ -69,7 +76,7 @@ module PortalHelper def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled) if is_plain_layout_enabled - "/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/articles/#{article_slug}" end diff --git a/app/javascript/entrypoints/portal.js b/app/javascript/entrypoints/portal.js index 42493615d..a1b58e390 100644 --- a/app/javascript/entrypoints/portal.js +++ b/app/javascript/entrypoints/portal.js @@ -1,9 +1,9 @@ import Rails from '@rails/ujs'; -import Turbolinks from 'turbolinks'; +import { Turbo } from '@hotwired/turbo-rails'; import '../portal/application.scss'; import { InitializationHelpers } from '../portal/portalHelpers'; Rails.start(); -Turbolinks.start(); +Turbo.start(); -document.addEventListener('turbolinks:load', InitializationHelpers.onLoad); +document.addEventListener('turbo:load', InitializationHelpers.onLoad); diff --git a/app/javascript/portal/application.scss b/app/javascript/portal/application.scss index 6d83f701a..00368d26a 100644 --- a/app/javascript/portal/application.scss +++ b/app/javascript/portal/application.scss @@ -55,6 +55,6 @@ body { } } -.turbolinks-progress-bar { +.turbo-progress-bar { background-color: var(--dynamic-portal-color); } diff --git a/app/javascript/portal/components/PublicArticleSearch.vue b/app/javascript/portal/components/PublicArticleSearch.vue index 086073fed..ebfe5b390 100644 --- a/app/javascript/portal/components/PublicArticleSearch.vue +++ b/app/javascript/portal/components/PublicArticleSearch.vue @@ -138,21 +138,40 @@ export default { this.isLoading = false; } }, + handleSubmit() { + const query = this.normalizedSearchTerm; + if (!query) return; + + const searchParams = new URLSearchParams({ query }); + const { theme, isPlainLayoutEnabled } = window.portalConfig; + + if (theme) searchParams.set('theme', theme); + if (isPlainLayoutEnabled === 'true') { + searchParams.set('show_plain_layout', 'true'); + } + + window.location.href = `/hc/${this.portalSlug}/${this.localeCode}/search?${searchParams.toString()}`; + }, }, }; diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 378d303b4..5f4fe0e03 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -14,6 +14,7 @@ export const FORMATTING = { 'link', 'bulletList', 'orderedList', + 'imageUpload', 'undo', 'redo', ], @@ -30,6 +31,7 @@ export const FORMATTING = { 'strike', 'bulletList', 'orderedList', + 'imageUpload', 'undo', 'redo', ], @@ -263,23 +265,3 @@ export const MARKDOWN_PATTERNS = [ ], }, ]; - -// Editor image resize options for Message Editor -export const MESSAGE_EDITOR_IMAGE_RESIZES = [ - { - name: 'Small', - height: '24px', - }, - { - name: 'Medium', - height: '48px', - }, - { - name: 'Large', - height: '72px', - }, - { - name: 'Original Size', - height: 'auto', - }, -]; diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index e4330b332..32f56172a 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -379,31 +379,6 @@ export const findNodeToInsertImage = (editorState, fileUrl) => { }; }; -/** - * Set URL with query and size. - * - * @param {Object} selectedImageNode - The current selected node. - * @param {Object} size - The size to set. - * @param {Object} editorView - The editor view. - */ -export function setURLWithQueryAndSize(selectedImageNode, size, editorView) { - if (selectedImageNode) { - // Create and apply the transaction - const tr = editorView.state.tr.setNodeMarkup( - editorView.state.selection.from, - null, - { - src: selectedImageNode.src, - height: size.height, - } - ); - - if (tr.docChanged) { - editorView.dispatch(tr); - } - } -} - /** * Strips unsupported markdown formatting from content based on the editor schema. * This ensures canned responses with rich formatting can be inserted into channels diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 00d1e83d4..7d32f63e0 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -16,7 +16,6 @@ import { insertAtCursor, removeSignature, replaceSignature, - setURLWithQueryAndSize, stripInlineBase64Images, stripUnsupportedFormatting, stripUnsupportedMarkdown, @@ -653,71 +652,6 @@ describe('findNodeToInsertImage', () => { }); }); -describe('setURLWithQueryAndSize', () => { - let selectedNode; - let editorView; - - beforeEach(() => { - selectedNode = { - setAttribute: vi.fn(), - }; - - const tr = { - setNodeMarkup: vi.fn().mockReturnValue({ - docChanged: true, - }), - }; - - const state = { - selection: { from: 0 }, - tr, - }; - - editorView = { - state, - dispatch: vi.fn(), - }; - }); - - it('updates the URL with the given size and updates the editor view', () => { - const size = { height: '20px' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view is updated - expect(editorView.dispatch).toHaveBeenCalledTimes(1); - }); - - it('updates the URL with the given size and updates the editor view with original size', () => { - const size = { height: 'auto' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view is updated - expect(editorView.dispatch).toHaveBeenCalledTimes(1); - }); - - it('does not update the editor view if the document has not changed', () => { - editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({ - docChanged: false, - }); - - const size = { height: '20px' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view dispatch was not called - expect(editorView.dispatch).not.toHaveBeenCalled(); - }); - - it('does not perform any operations if selectedNode is not provided', () => { - setURLWithQueryAndSize(null, { height: '20px' }, editorView); - - // Ensure the dispatch method wasn't called - expect(editorView.dispatch).not.toHaveBeenCalled(); - }); -}); - describe('getContentNode', () => { let mockEditorView; diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 7b1b49493..3eb83fd31 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -233,6 +233,7 @@ "TIP_AUDIORECORDER_ERROR": "Could not open the audio", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "DRAG_DROP": "Drag and drop here to attach", + "IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully", "START_AUDIO_RECORDING": "Start audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording", "COPILOT_THINKING": "Copilot is thinking", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue index b0dab9774..bf6f01f82 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue @@ -48,7 +48,6 @@ const updateSignature = () => { :placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')" channel-type="Context::MessageSignature" :enable-suggestions="false" - show-image-resize-toolbar />
{ +const setImageSizing = inlineToken => { const imgSrc = inlineToken.attrGet('src'); if (!imgSrc) return; const url = new URL(imgSrc); + const width = url.searchParams.get('cw_image_width'); + if (width) { + inlineToken.attrSet( + 'style', + `width: ${width}; max-width: 100%; height: auto;` + ); + return; + } const height = url.searchParams.get('cw_image_height'); - if (!height) return; - inlineToken.attrSet('style', `height: ${height};`); + if (height) inlineToken.attrSet('style', `height: ${height};`); }; const processInlineToken = blockToken => { blockToken.children.forEach(inlineToken => { if (inlineToken.type === 'image') { - setImageHeight(inlineToken); + setImageSizing(inlineToken); } }); }; const imgResizeManager = md => { - // Custom rule for image resize in markdown - // If the image url has a query param cw_image_height, then add a style attribute to the image - md.core.ruler.after('inline', 'add-image-height', state => { + // If the image URL carries a cw_image_width or cw_image_height query param, + // add an inline style attribute so the rendered respects the agent's + // resize choice. Width takes precedence (HC drag-resize); height is kept for + // legacy messages and the message-signature use case. + md.core.ruler.after('inline', 'add-image-sizing', state => { state.tokens.forEach(blockToken => { if (blockToken.type === 'inline') { processInlineToken(blockToken); diff --git a/lib/base_markdown_renderer.rb b/lib/base_markdown_renderer.rb index f530e71ee..2329dea6d 100644 --- a/lib/base_markdown_renderer.rb +++ b/lib/base_markdown_renderer.rb @@ -1,9 +1,9 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer def image(node) src, title = extract_img_attributes(node) - height = extract_image_height(src) + sizing_style = extract_image_sizing_style(src) - render_img_tag(src, title, height) + render_img_tag(src, title, sizing_style) end private @@ -15,9 +15,25 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer ] end - def extract_image_height(src) + # Drag-resize from the reply editor encodes the chosen width as cw_image_width + # on the URL; the older message-signature picker uses cw_image_height. Width + # wins when both are set so the agent's most recent intent is honored. + def extract_image_sizing_style(src) query_params = parse_query_params(src) - query_params['cw_image_height']&.first + width = sanitize_pixel_value(query_params['cw_image_width']&.first) + return "width: #{width}; max-width: 100%; height: auto;" if width + + height = sanitize_pixel_value(query_params['cw_image_height']&.first) + height ? "height: #{height};" : nil + end + + # Only allow a bounded `px` value so the decoded query param can't + # break out of the inline style attribute (HTML attribute injection). + def sanitize_pixel_value(raw) + return unless raw =~ /\A(\d+)px\z/ + + px = Regexp.last_match(1).to_i + "#{px}px" if px.between?(1, 2000) end def parse_query_params(url) @@ -27,13 +43,13 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer {} end - def render_img_tag(src, title, height = nil) + def render_img_tag(src, title, sizing_style = nil) title_attribute = title.present? ? " title=\"#{title}\"" : '' - # Use inline style instead of the HTML height attribute: email clients and - # the in-app Letter view both run images through CSS (e.g. prose / + # Use inline style instead of HTML width/height attributes: email clients + # and the in-app Letter view both run images through CSS (e.g. prose / # lettersanitizer's `img { height: auto }`) which overrides presentational # attributes. Inline style has higher specificity and survives. - style_attribute = height ? " style=\"height: #{height};\"" : '' + style_attribute = sizing_style ? " style=\"#{sizing_style}\"" : '' plain do # plain ensures that the content is not wrapped in a paragraph tag diff --git a/package.json b/package.json index 081c706c6..d8527051d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.13", + "@chatwoot/prosemirror-schema": "1.3.17", "@chatwoot/utils": "^0.0.55", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afcf5b60f..a4b61061c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.13 - version: 1.3.13 + specifier: 1.3.17 + version: 1.3.17 '@chatwoot/utils': specifier: ^0.0.55 version: 0.0.55 @@ -458,8 +458,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.13': - resolution: {integrity: sha512-T6FBUinMJbwDCD7975g8M/Tsn2+G3O2pTGIXdcLkMRpbAAC6mVdl4ZcZektlt5y/PVmPVqNHPsfee1XB/C3vAw==} + '@chatwoot/prosemirror-schema@1.3.17': + resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==} '@chatwoot/utils@0.0.55': resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==} @@ -5128,7 +5128,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.13': + '@chatwoot/prosemirror-schema@1.3.17': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.7.1 diff --git a/spec/lib/base_markdown_renderer_spec.rb b/spec/lib/base_markdown_renderer_spec.rb index f8bdae4be..082f9fff6 100644 --- a/spec/lib/base_markdown_renderer_spec.rb +++ b/spec/lib/base_markdown_renderer_spec.rb @@ -11,8 +11,33 @@ describe BaseMarkdownRenderer do describe '#image' do context 'when image has a height' do it 'renders the img tag with the correct attributes' do - markdown = '![Sample Title](https://example.com/image.jpg?cw_image_height=100)' - expect(render_markdown(markdown)).to include('') + markdown = '![Sample Title](https://example.com/image.jpg?cw_image_height=100px)' + expect(render_markdown(markdown)).to include('') + end + end + + context 'when image has a width' do + it 'renders the img tag with the correct attributes' do + markdown = '![Sample Title](https://example.com/image.jpg?cw_image_width=200px)' + expect(render_markdown(markdown)).to include( + '' + ) + end + end + + context 'when the sizing param contains an attribute-injection payload' do + it 'drops the malicious height value' do + markdown = '![x](https://example.com/image.jpg?cw_image_height=1px%22%20onmouseover%3D%22alert(1))' + rendered = render_markdown(markdown) + expect(rendered).not_to include('style=') + expect(rendered).not_to include('onmouseover="') + end + + it 'drops the malicious width value' do + markdown = '![x](https://example.com/image.jpg?cw_image_width=1px%22%20onmouseover%3D%22alert(1))' + rendered = render_markdown(markdown) + expect(rendered).not_to include('style=') + expect(rendered).not_to include('onmouseover="') end end From 8e42307bdcdd3d1c4f651c6da657528f28a56577 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 3 Jun 2026 15:56:54 +0530 Subject: [PATCH 25/45] fix: improve email inbox IMAP and SMTP compatibility (#14589) Fetch IMAP message content using `BODY.PEEK[]` instead of `RFC822` to avoid provider-specific parser failures while preserving unread state. This also applies the existing SMTP timeout configuration to custom SMTP email-channel replies, so provider SMTP responses have enough time to complete. Fixes: https://github.com/chatwoot/chatwoot/issues/12762 ## Why Some IMAP providers can return responses for `FETCH RFC822` that Ruby `net-imap` fails to parse with: `Net::IMAP::ResponseParseError: unexpected RPAR (expected ATOM or NIL)` We reproduced this with iCloud IMAP. Authentication, `INBOX` selection, and header fetches worked, but fetching full message content with `RFC822` failed before Chatwoot received a `Mail::Message`. The same mailbox successfully returned full message content when fetched with `BODY.PEEK[]`. > During end-to-end iCloud validation, inbound fetch worked after the IMAP change, but outbound replies through the custom SMTP settings could still fail with a socket read timeout. The OAuth SMTP path already used explicit SMTP timeout values; the custom SMTP path was relying on mailer defaults instead. ## What this change does - Replaces the full message fetch from `RFC822` to `BODY.PEEK[]` - Reads the returned message content from `BODY[]`, which is how `net-imap` exposes the response attribute - Keeps the existing `BODY.PEEK[HEADER]` header-fetch behavior unchanged - Applies `SMTP_OPEN_TIMEOUT` and `SMTP_READ_TIMEOUT` to custom SMTP email-channel replies - Defaults custom SMTP reply delivery to `open_timeout: 15` and `read_timeout: 30` - Updates IMAP service specs for standard and Microsoft IMAP fetch flows - Updates mailer specs for custom SMTP timeout settings `BODY.PEEK[]` is preferable here because it fetches the full message content without marking messages as read. ## Validation - Configured a local email inbox against iCloud IMAP and SMTP - Confirmed `FETCH RFC822` reproduces `Net::IMAP::ResponseParseError: unexpected RPAR (expected ATOM or NIL)` - Confirmed `BODY[]` and `BODY.PEEK[]` fetch the same mailbox successfully - Confirmed Chatwoot imports iCloud messages after the IMAP change - Sent two outbound replies from the Chatwoot UI through iCloud SMTP after applying the timeout settings - Confirmed both UI-created outbound messages were marked `sent`, had iCloud SMTP `source_id` values, and had no `external_error` - Ran `bundle exec rspec spec/services/imap/fetch_email_service_spec.rb spec/services/imap/microsoft_fetch_email_service_spec.rb` - Ran `bundle exec rspec spec/mailers/conversation_reply_mailer_spec.rb` --- .../conversation_reply_mailer_helper.rb | 11 ++++++++-- app/services/imap/base_fetch_email_service.rb | 5 +++-- .../mailers/conversation_reply_mailer_spec.rb | 20 +++++++++++++++++++ .../services/imap/fetch_email_service_spec.rb | 14 ++++++------- .../microsoft_fetch_email_service_spec.rb | 12 +++++------ 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/app/mailers/conversation_reply_mailer_helper.rb b/app/mailers/conversation_reply_mailer_helper.rb index dc3e0c3fd..88266c14d 100644 --- a/app/mailers/conversation_reply_mailer_helper.rb +++ b/app/mailers/conversation_reply_mailer_helper.rb @@ -54,8 +54,7 @@ module ConversationReplyMailerHelper tls: false, enable_starttls_auto: true, openssl_verify_mode: 'none', - open_timeout: 15, - read_timeout: 15, + **smtp_timeout_settings, authentication: 'xoauth2' } end @@ -72,6 +71,7 @@ module ConversationReplyMailerHelper tls: @channel.smtp_enable_ssl_tls, enable_starttls_auto: @channel.smtp_enable_starttls_auto, openssl_verify_mode: @channel.smtp_openssl_verify_mode, + **smtp_timeout_settings, authentication: @channel.smtp_authentication } @@ -79,6 +79,13 @@ module ConversationReplyMailerHelper @options[:delivery_method_options] = smtp_settings end + def smtp_timeout_settings + { + open_timeout: ENV['SMTP_OPEN_TIMEOUT'].presence || 15, + read_timeout: ENV['SMTP_READ_TIMEOUT'].presence || 30 + }.transform_values(&:to_i) + end + def email_smtp_enabled? @inbox.inbox_type == 'Email' && @channel.smtp_enabled end diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index 5a6d3537c..24dd22589 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -58,8 +58,9 @@ class Imap::BaseFetchEmailService return if email_already_present?(channel, message_id) - # Fetch the original mail content using the sequence no - mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822'] + # Fetch the original mail content using the sequence no. + # BODY.PEEK[] avoids RFC822 parser failures seen with some IMAP servers. + mail_str = imap_client.fetch(seq_no, 'BODY.PEEK[]')[0].attr['BODY[]'] if mail_str.blank? Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>." diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index 86a2363e9..df4c4f41f 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -462,6 +462,26 @@ RSpec.describe ConversationReplyMailer do expect(mail.delivery_method.settings.empty?).to be false expect(mail.delivery_method.settings[:address]).to eq 'smtp.gmail.com' expect(mail.delivery_method.settings[:port]).to eq 587 + expect(mail.delivery_method.settings[:open_timeout]).to eq 15 + expect(mail.delivery_method.settings[:read_timeout]).to eq 30 + end + + it 'uses configured smtp timeout values' do + with_modified_env SMTP_OPEN_TIMEOUT: '10', SMTP_READ_TIMEOUT: '30' do + mail = described_class.email_reply(message) + + expect(mail.delivery_method.settings[:open_timeout]).to eq 10 + expect(mail.delivery_method.settings[:read_timeout]).to eq 30 + end + end + + it 'uses default smtp timeout values when env values are blank' do + with_modified_env SMTP_OPEN_TIMEOUT: '', SMTP_READ_TIMEOUT: '' do + mail = described_class.email_reply(message) + + expect(mail.delivery_method.settings[:open_timeout]).to eq 15 + expect(mail.delivery_method.settings[:read_timeout]).to eq 30 + end end it 'renders sender name in the from address' do diff --git a/spec/services/imap/fetch_email_service_spec.rb b/spec/services/imap/fetch_email_service_spec.rb index a40a46343..1f74cf0b7 100644 --- a/spec/services/imap/fetch_email_service_spec.rb +++ b/spec/services/imap/fetch_email_service_spec.rb @@ -80,11 +80,11 @@ RSpec.describe Imap::FetchEmailService do travel_to '26.10.2020 10:00'.to_datetime do email_object = create_inbound_email_from_fixture('only_text.eml') email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id) - imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id) + imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id) allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1]) allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header]) - allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail]) + allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail]) allow(imap).to receive(:logout) result = described_class.new(channel: imap_email_channel).perform @@ -93,7 +93,7 @@ RSpec.describe Imap::FetchEmailService do expect(result[0].message_id).to eq email_object.message_id expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020]) expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]') - expect(imap).to have_received(:fetch).with(1, 'RFC822') + expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]') expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{imap_email_channel.email}, found 1.") expect(imap).to have_received(:logout) end @@ -115,7 +115,7 @@ RSpec.describe Imap::FetchEmailService do expect(result.length).to eq 0 expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020]) expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]') - expect(imap).not_to have_received(:fetch).with(1, 'RFC822') + expect(imap).not_to have_received(:fetch).with(1, 'BODY.PEEK[]') end end @@ -129,12 +129,12 @@ RSpec.describe Imap::FetchEmailService do Net::IMAP::FetchData.new(seq_num, 'BODY[HEADER]' => eml_content_without_message_id) end valid_email_header = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[HEADER]' => eml_content_with_message_id) - imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'RFC822' => eml_content_with_message_id) + imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[]' => eml_content_with_message_id) allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return(empty_message_id_seq_nums + [valid_message_seq_num]) allow(imap).to receive(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]').and_return(empty_message_id_headers) allow(imap).to receive(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]').and_return([valid_email_header]) - allow(imap).to receive(:fetch).with(valid_message_seq_num, 'RFC822').and_return([imap_fetch_mail]) + allow(imap).to receive(:fetch).with(valid_message_seq_num, 'BODY.PEEK[]').and_return([imap_fetch_mail]) allow(imap).to receive(:logout) result = described_class.new(channel: imap_email_channel).perform @@ -143,7 +143,7 @@ RSpec.describe Imap::FetchEmailService do expect(result[0].message_id).to eq email_object.message_id expect(imap).to have_received(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]') expect(imap).to have_received(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]') - expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'RFC822') + expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'BODY.PEEK[]') end end end diff --git a/spec/services/imap/microsoft_fetch_email_service_spec.rb b/spec/services/imap/microsoft_fetch_email_service_spec.rb index a4a0a62d1..cc20d5b35 100644 --- a/spec/services/imap/microsoft_fetch_email_service_spec.rb +++ b/spec/services/imap/microsoft_fetch_email_service_spec.rb @@ -30,11 +30,11 @@ RSpec.describe Imap::MicrosoftFetchEmailService do travel_to '26.10.2020 10:00'.to_datetime do email_object = create_inbound_email_from_fixture('only_text.eml') email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id) - imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id) + imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id) allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1]) allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header]) - allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail]) + allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail]) allow(imap).to receive(:logout) result = described_class.new(channel: microsoft_channel).perform @@ -45,7 +45,7 @@ RSpec.describe Imap::MicrosoftFetchEmailService do expect(result[0].message_id).to eq email_object.message_id expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020]) expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]') - expect(imap).to have_received(:fetch).with(1, 'RFC822') + expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]') expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.") end end @@ -56,11 +56,11 @@ RSpec.describe Imap::MicrosoftFetchEmailService do travel_to '26.10.2020 10:00'.to_datetime do email_object = create_inbound_email_from_fixture('only_text.eml') email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id) - imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id) + imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id) allow(imap).to receive(:search).with(%w[SINCE 18-Oct-2020]).and_return([1]) allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header]) - allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail]) + allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail]) allow(imap).to receive(:logout) result = described_class.new(channel: microsoft_channel, interval: 8).perform @@ -71,7 +71,7 @@ RSpec.describe Imap::MicrosoftFetchEmailService do expect(result[0].message_id).to eq email_object.message_id expect(imap).to have_received(:search).with(%w[SINCE 18-Oct-2020]) expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]') - expect(imap).to have_received(:fetch).with(1, 'RFC822') + expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]') expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.") end end From de893910315af0c891c18c2b8859af20c4ec8866 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:59:12 +0530 Subject: [PATCH 26/45] fix: use hang-up handset icon for reject/end call button (#14640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Linear Ticket https://linear.app/chatwoot/issue/CW-7261/call-window-theme-alignment ## Description Updates the call window's reject/end button to use the hang-up handset icon (the same handset as the accept button, rotated) instead of the phone-with-X, matching the design. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## Screenshots Screenshot 2026-06-03 at 3 41 10 PM ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings --- app/javascript/dashboard/components-next/call/CallCard.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/components-next/call/CallCard.vue b/app/javascript/dashboard/components-next/call/CallCard.vue index 207129282..55da3dacf 100644 --- a/app/javascript/dashboard/components-next/call/CallCard.vue +++ b/app/javascript/dashboard/components-next/call/CallCard.vue @@ -197,9 +197,9 @@ const channelIcon = computed(() => { ? $t('CONVERSATION.VOICE_WIDGET.END_CALL') : $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL') " - icon="i-ph-phone-x-bold" + icon="i-ph-phone-bold" ruby - class="!rounded-full rotate-[134deg]" + class="!rounded-full rotate-[135deg]" @click="isOngoing ? $emit('end') : $emit('reject')" />
From 0e87519ecdf7f9e22d21697308ddce5b5e5e1132 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:59:23 +0530 Subject: [PATCH 27/45] feat: add mute button for Twilio calls (#14637) ## Linear Ticket https://linear.app/chatwoot/issue/CW-7264/add-mute-button-in-twilio-calls ## Description Adds mute support for Twilio voice calls. Previously the mute button was shown only for WhatsApp calls (which toggle the local mic track in the browser), so Twilio calls had no way to mute. The call widget now routes mute by provider: WhatsApp continues to toggle the local mic track, while Twilio uses the Voice SDK connection's native `mute()`. The mute button is now shown for any active call, and unmute works symmetrically. ## 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 - [x] I have made corresponding changes to the documentation - [x] 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 --- .../api/channel/voice/twilioVoiceClient.js | 6 ++++++ .../components-next/call/FloatingCallWidget.vue | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js index 13f61a16c..14dd56ec9 100644 --- a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js +++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js @@ -48,6 +48,12 @@ class TwilioVoiceClient extends EventTarget { return !!this.activeConnection; } + setMuted(shouldMute) { + if (!this.activeConnection) return false; + this.activeConnection.mute(shouldMute); + return shouldMute; + } + endClientCall() { if (this.activeConnection) { this.activeConnection.disconnect(); diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue index 86d9e979c..f8dfabb35 100644 --- a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue @@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'; import { useStore } from 'vuex'; import { useCallSession } from 'dashboard/composables/useCallSession'; import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession'; +import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox'; import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants'; @@ -29,8 +30,8 @@ const { formattedCallDuration, } = useCallSession(); -// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and -// don't expose a mic track on the browser side. +// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses +// the Voice SDK connection's native mute. Both surface the same button. const isMuted = ref(false); const isWhatsappActive = computed( () => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP @@ -63,7 +64,11 @@ const stackedCardState = call => const toggleMute = () => { isMuted.value = !isMuted.value; - setWhatsappCallMuted(isMuted.value); + if (isWhatsappActive.value) { + setWhatsappCallMuted(isMuted.value); + } else { + TwilioVoiceClient.setMuted(isMuted.value); + } }; watch(hasActiveCall, active => { @@ -256,7 +261,7 @@ onBeforeUnmount(stopRingtone); :call-info="getCallInfo(activeCall || primaryIncomingCall)" :duration="hasActiveCall ? formattedCallDuration : ''" :is-muted="isMuted" - :show-mute="hasActiveCall && isWhatsappActive" + :show-mute="hasActiveCall" @accept="handleJoinCall(primaryIncomingCall)" @reject="rejectIncomingCall(primaryIncomingCall?.callSid)" @dismiss="dismissCall(primaryIncomingCall?.callSid)" From 94ddd98050f2a0b4b4c6ed96a7494e96f9ee28c7 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:00:04 +0530 Subject: [PATCH 28/45] chore: update the voice call ringtone (#14636) ## Linear Ticket https://linear.app/chatwoot/issue/CW-7260/update-the-voice-call-ringtone ## Description Updates the incoming voice call ringtone. The dashboard call widget now plays the new tone while an inbound call is unanswered, replacing the previous bell sound. ## 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 - [x] 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 --- .../components-next/call/FloatingCallWidget.vue | 2 +- public/audio/dashboard/ringtone.mp3 | Bin 0 -> 73197 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 public/audio/dashboard/ringtone.mp3 diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue index f8dfabb35..7758500a0 100644 --- a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue @@ -12,7 +12,7 @@ import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilit import CallCard from 'dashboard/components-next/call/CallCard.vue'; import countriesList from 'shared/constants/countries.js'; -const RINGTONE_URL = '/audio/dashboard/bell.mp3'; +const RINGTONE_URL = '/audio/dashboard/ringtone.mp3'; const route = useRoute(); const router = useRouter(); diff --git a/public/audio/dashboard/ringtone.mp3 b/public/audio/dashboard/ringtone.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c2af2b6d1d5c828c680fa90bf5c227c71402f34d GIT binary patch literal 73197 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 Date: Wed, 3 Jun 2026 16:01:14 +0530 Subject: [PATCH 29/45] feat: add Voice Calls feature card to settings showcase (#14635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Linear Ticket https://linear.app/chatwoot/issue/CW-7250/add-voice-calls-to-self-hosted-super-admin-feature-list ## Description Adds a **Voice Calls** card to the Super Admin → Settings → Features showcase so self-hostedadmins can see at a glance whether voice calling is available on the installation. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## Screenshots? Screenshot 2026-06-03 at 3 15 22 PM ## 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 --- app/helpers/super_admin/features.yml | 6 ++++++ app/views/super_admin/application/_icons.html.erb | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index f21a97f78..6489d0194 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -34,6 +34,12 @@ disable_branding: enabled: <%= (ChatwootHub.pricing_plan != 'community') %> icon: 'icon-sailbot-fill' enterprise: true +voice_calls: + name: 'Voice Calls' + description: 'Enable voice calling capabilities for your agents and customers.' + enabled: <%= (ChatwootHub.pricing_plan != 'community') %> + icon: 'icon-voice-line' + enterprise: true # ------- Product Features ------- # help_center: diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb index e80d1e164..39253c969 100644 --- a/app/views/super_admin/application/_icons.html.erb +++ b/app/views/super_admin/application/_icons.html.erb @@ -128,6 +128,10 @@ + + + + From 18ef019cd46b85d1003ae943d9f497c27d82f12c Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:11:32 +0530 Subject: [PATCH 30/45] fix: improve article editor typography & nested lists (#14572) --- .../dashboard/components/widgets/WootWriter/Editor.vue | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index a8f2d0a2c..09dc23819 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -979,10 +979,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); @apply text-n-slate-11; } } - - ol li { - @apply list-item list-decimal; - } } } From eaffad12e7b5dfc47f629f2a32b4420dc03cbdd3 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:45:19 +0530 Subject: [PATCH 31/45] feat(langfuse): propagate observation metadata for evals (#14634) # Pull Request Template ## Description We need to pass on trace level attributes down to the spans inside them like tool calls, observations, etc. This way, we can filter observations based on trace level attributes. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## 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. Attributes added to observation metadata for easy filtering image added a `generation_stage` to differentiate llm_calls that call tools vs those that generate a `final_response` CleanShot 2026-06-03 at 15 11 09@2x propagated attributes to tool calls for future use image ## 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 --- .../captain/chat_generation_recorder.rb | 15 +++- lib/captain/tool_instrumentation.rb | 19 ++--- lib/integrations/llm_instrumentation.rb | 38 +++------- .../llm_instrumentation_completion_helpers.rb | 8 -- .../llm_instrumentation_constants.rb | 1 + .../llm_instrumentation_context.rb | 41 ++++++++++ .../llm_instrumentation_helpers.rb | 53 +++++++++++-- lib/integrations/llm_instrumentation_spans.rb | 19 +++++ lib/opentelemetry_config.rb | 5 +- .../llm/assistant_chat_service_spec.rb | 24 ++++++ .../integrations/llm_instrumentation_spec.rb | 75 ++++++++++++++++++- 11 files changed, 246 insertions(+), 52 deletions(-) create mode 100644 lib/integrations/llm_instrumentation_context.rb diff --git a/enterprise/app/helpers/captain/chat_generation_recorder.rb b/enterprise/app/helpers/captain/chat_generation_recorder.rb index cd631fb16..63bcdc276 100644 --- a/enterprise/app/helpers/captain/chat_generation_recorder.rb +++ b/enterprise/app/helpers/captain/chat_generation_recorder.rb @@ -10,6 +10,7 @@ module Captain::ChatGenerationRecorder # Create a generation span with model and token info for Langfuse cost calculation. # Note: span duration will be near-zero since we create and end it immediately, but token counts are what Langfuse uses for cost calculation. tracer.in_span("llm.captain.#{feature_name}.generation") do |span| + apply_current_langfuse_attributes(span) set_generation_span_attributes(span, chat, message) end rescue StandardError => e @@ -37,11 +38,23 @@ module Captain::ChatGenerationRecorder ATTR_GEN_AI_USAGE_INPUT_TOKENS => message.input_tokens, ATTR_GEN_AI_USAGE_OUTPUT_TOKENS => message.respond_to?(:output_tokens) ? message.output_tokens : nil, ATTR_LANGFUSE_OBSERVATION_INPUT => format_input_messages(chat), - ATTR_LANGFUSE_OBSERVATION_OUTPUT => message.respond_to?(:content) ? message.content.to_s : nil + ATTR_LANGFUSE_OBSERVATION_OUTPUT => message.respond_to?(:content) ? message.content.to_s : nil, + format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'generation_stage') => generation_stage(message) } end def format_input_messages(chat) chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json end + + def generation_stage(message) + message_has_tool_calls?(message) ? 'tool_call' : 'final_response' + end + + def message_has_tool_calls?(message) + return false unless message.respond_to?(:tool_calls) + + tool_calls = message.tool_calls + tool_calls.respond_to?(:any?) && tool_calls.any? + end end diff --git a/lib/captain/tool_instrumentation.rb b/lib/captain/tool_instrumentation.rb index 157aab829..3c4a1c76e 100644 --- a/lib/captain/tool_instrumentation.rb +++ b/lib/captain/tool_instrumentation.rb @@ -10,12 +10,14 @@ module Captain::ToolInstrumentation response = nil executed = false - tracer.in_span(params[:span_name]) do |span| - set_tool_session_attributes(span, params) - response = yield - executed = true - span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json) - set_tool_session_error_attributes(span, response) if response.is_a?(Hash) + with_propagated_langfuse_attributes(params) do + tracer.in_span(params[:span_name]) do |span| + set_tool_session_attributes(span, params) + response = yield + executed = true + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json) + set_tool_session_error_attributes(span, response) if response.is_a?(Hash) + end end response rescue StandardError => e @@ -24,9 +26,7 @@ module Captain::ToolInstrumentation end def set_tool_session_attributes(span, params) - span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] - span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present? - span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) + set_metadata_attributes(span, params) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) end @@ -43,6 +43,7 @@ module Captain::ToolInstrumentation return unless message.respond_to?(:role) && message.role.to_s == 'assistant' tracer.in_span("llm.#{event_name}.generation") do |span| + apply_current_langfuse_attributes(span) span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai') span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb index 326bb901e..0257f5c3a 100644 --- a/lib/integrations/llm_instrumentation.rb +++ b/lib/integrations/llm_instrumentation.rb @@ -29,16 +29,18 @@ module Integrations::LlmInstrumentation result = nil executed = false - tracer.in_span(params[:span_name]) do |span| - set_metadata_attributes(span, params) + with_propagated_langfuse_attributes(params) do + tracer.in_span(params[:span_name]) do |span| + set_metadata_attributes(span, params) - # By default, the input and output of a trace are set from the root observation - span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) - result = yield - executed = true - span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json) - set_error_attributes(span, result) if result.is_a?(Hash) - result + # By default, the input and output of a trace are set from the root observation + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) + result = yield + executed = true + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json) + set_error_attributes(span, result) if result.is_a?(Hash) + result + end end rescue StandardError => e ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception @@ -51,6 +53,7 @@ module Integrations::LlmInstrumentation return yield unless ChatwootApp.otel_enabled? tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span| + apply_current_langfuse_attributes(span) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool') span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json) result = yield @@ -96,23 +99,6 @@ module Integrations::LlmInstrumentation end end - def instrument_with_span(span_name, params, &) - result = nil - executed = false - tracer.in_span(span_name) do |span| - track_result = lambda do |r| - executed = true - result = r - end - yield(span, track_result) - end - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception - raise unless executed - - result - end - private def resolve_account(params) diff --git a/lib/integrations/llm_instrumentation_completion_helpers.rb b/lib/integrations/llm_instrumentation_completion_helpers.rb index 551d0780f..26af2aae1 100644 --- a/lib/integrations/llm_instrumentation_completion_helpers.rb +++ b/lib/integrations/llm_instrumentation_completion_helpers.rb @@ -10,7 +10,6 @@ module Integrations::LlmInstrumentationCompletionHelpers span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model]) span.set_attribute('embedding.input_length', params[:input]&.length || 0) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s) - set_common_span_metadata(span, params) end def set_audio_transcription_span_attributes(span, params) @@ -18,7 +17,6 @@ module Integrations::LlmInstrumentationCompletionHelpers span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'whisper-1') span.set_attribute('audio.duration_seconds', params[:duration]) if params[:duration] span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:file_path].to_s) if params[:file_path] - set_common_span_metadata(span, params) end def set_moderation_span_attributes(span, params) @@ -26,12 +24,6 @@ module Integrations::LlmInstrumentationCompletionHelpers span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'text-moderation-latest') span.set_attribute('moderation.input_length', params[:input]&.length || 0) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s) - set_common_span_metadata(span, params) - end - - def set_common_span_metadata(span, params) - span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] - span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) if params[:feature_name] end def set_embedding_result_attributes(span, result) diff --git a/lib/integrations/llm_instrumentation_constants.rb b/lib/integrations/llm_instrumentation_constants.rb index dfe1e7704..f274d1145 100644 --- a/lib/integrations/llm_instrumentation_constants.rb +++ b/lib/integrations/llm_instrumentation_constants.rb @@ -29,4 +29,5 @@ module Integrations::LlmInstrumentationConstants ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type' ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input' ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output' + ATTR_LANGFUSE_OBSERVATION_METADATA = 'langfuse.observation.metadata.%s' end diff --git a/lib/integrations/llm_instrumentation_context.rb b/lib/integrations/llm_instrumentation_context.rb new file mode 100644 index 000000000..27b1eb2b2 --- /dev/null +++ b/lib/integrations/llm_instrumentation_context.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Integrations::LlmInstrumentationContext + LANGFUSE_ATTRIBUTES_KEY = :llm_instrumentation_langfuse_attributes + LANGFUSE_OBSERVATION_METADATA_KEY = :llm_instrumentation_langfuse_observation_metadata_attributes + + private + + def with_propagated_langfuse_attributes(params) + previous_attributes = current_langfuse_attributes + previous_observation_metadata_attributes = current_observation_metadata_attributes + self.current_langfuse_attributes = previous_attributes.merge(propagated_langfuse_attributes(params)) + self.current_observation_metadata_attributes = previous_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params)) + + yield + ensure + self.current_langfuse_attributes = previous_attributes + self.current_observation_metadata_attributes = previous_observation_metadata_attributes + end + + def apply_current_langfuse_attributes(span) + set_langfuse_attributes(span, current_langfuse_attributes) + set_langfuse_attributes(span, current_observation_metadata_attributes) + end + + def current_langfuse_attributes + ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] || {} + end + + def current_langfuse_attributes=(attrs) + ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] = attrs + end + + def current_observation_metadata_attributes + ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] || {} + end + + def current_observation_metadata_attributes=(attrs) + ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] = attrs + end +end diff --git a/lib/integrations/llm_instrumentation_helpers.rb b/lib/integrations/llm_instrumentation_helpers.rb index 129092ed4..debbfaeda 100644 --- a/lib/integrations/llm_instrumentation_helpers.rb +++ b/lib/integrations/llm_instrumentation_helpers.rb @@ -2,6 +2,7 @@ module Integrations::LlmInstrumentationHelpers include Integrations::LlmInstrumentationConstants + include Integrations::LlmInstrumentationContext include Integrations::LlmInstrumentationCompletionHelpers def determine_provider(model_name) @@ -51,15 +52,55 @@ module Integrations::LlmInstrumentationHelpers end def set_metadata_attributes(span, params) - session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil - span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] - span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present? - span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) + set_langfuse_attributes(span, current_langfuse_attributes.merge(propagated_langfuse_attributes(params))) + set_langfuse_attributes(span, current_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params))) + end - return unless params[:metadata].is_a?(Hash) + def propagated_langfuse_attributes(params) + attrs = {} + session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil + + attrs[ATTR_LANGFUSE_USER_ID] = params[:account_id].to_s if params[:account_id] + attrs[ATTR_LANGFUSE_SESSION_ID] = session_id if session_id.present? + attrs[ATTR_LANGFUSE_TAGS] = [params[:feature_name].to_s] if params[:feature_name].present? + + return attrs unless params[:metadata].is_a?(Hash) params[:metadata].each do |key, value| - span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s) + attrs[format(ATTR_LANGFUSE_METADATA, key)] = value.to_s + end + + attrs + end + + def propagated_observation_metadata_attributes(params) + attrs = {} + session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil + + add_observation_metadata(attrs, 'user_id', params[:account_id]) + add_observation_metadata(attrs, 'account_id', params[:account_id]) + add_observation_metadata(attrs, 'session_id', session_id) + add_observation_metadata(attrs, 'trace_tags', [params[:feature_name]].to_json) + add_observation_metadata(attrs, 'feature_name', params[:feature_name]) + + return attrs unless params[:metadata].is_a?(Hash) + + params[:metadata].each do |key, value| + add_observation_metadata(attrs, key, value) + end + + attrs + end + + def add_observation_metadata(attrs, key, value) + return if value.blank? + + attrs[format(ATTR_LANGFUSE_OBSERVATION_METADATA, key)] = value.to_s + end + + def set_langfuse_attributes(span, attrs) + attrs.each do |key, value| + span.set_attribute(key, value) end end end diff --git a/lib/integrations/llm_instrumentation_spans.rb b/lib/integrations/llm_instrumentation_spans.rb index 85ea599f8..2def9749d 100644 --- a/lib/integrations/llm_instrumentation_spans.rb +++ b/lib/integrations/llm_instrumentation_spans.rb @@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans tool_name = tool_call.name.to_s span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name)) + apply_current_langfuse_attributes(span) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool') span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json) @@ -61,6 +62,24 @@ module Integrations::LlmInstrumentationSpans Rails.logger.warn "Failed to end tool span: #{e.message}" end + def instrument_with_span(span_name, params, &) + result = nil + executed = false + tracer.in_span(span_name) do |span| + set_metadata_attributes(span, params) + track_result = lambda do |r| + executed = true + result = r + end + yield(span, track_result) + end + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception + raise unless executed + + result + end + private def set_llm_turn_request_attributes(span, params) diff --git a/lib/opentelemetry_config.rb b/lib/opentelemetry_config.rb index 5ed17e098..32be413d0 100644 --- a/lib/opentelemetry_config.rb +++ b/lib/opentelemetry_config.rb @@ -72,7 +72,10 @@ module OpentelemetryConfig config = { endpoint: traces_endpoint, - headers: { 'Authorization' => "Basic #{auth_header}" } + headers: { + 'Authorization' => "Basic #{auth_header}", + 'x-langfuse-ingestion-version' => '4' + } } config[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE if Rails.env.development? diff --git a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb index 9d233943e..6b2cc55c8 100644 --- a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb +++ b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb @@ -39,6 +39,30 @@ RSpec.describe Captain::Llm::AssistantChatService do allow(mock_chat).to receive(:ask).and_return(mock_response) service.generate_response(message_history: [{ role: 'user', content: 'Hello' }]) end + + it 'marks final response generations for observation-level evaluators' do + service = described_class.new(assistant: assistant, conversation: conversation) + message = instance_double(RubyLLM::Message, content: 'Final answer', input_tokens: 10, output_tokens: 20, tool_calls: {}) + + attributes = service.send(:generation_attributes, mock_chat, message) + + expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('final_response') + end + + it 'marks tool call generations separately from final responses' do + service = described_class.new(assistant: assistant, conversation: conversation) + message = instance_double( + RubyLLM::Message, + content: '', + input_tokens: 10, + output_tokens: 20, + tool_calls: { 'call_1' => instance_double(RubyLLM::ToolCall) } + ) + + attributes = service.send(:generation_attributes, mock_chat, message) + + expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('tool_call') + end end describe 'image analysis' do diff --git a/spec/lib/integrations/llm_instrumentation_spec.rb b/spec/lib/integrations/llm_instrumentation_spec.rb index 0be62f437..f291cd7b2 100644 --- a/spec/lib/integrations/llm_instrumentation_spec.rb +++ b/spec/lib/integrations/llm_instrumentation_spec.rb @@ -144,7 +144,10 @@ RSpec.describe Integrations::LlmInstrumentation do expect(mock_span).to have_received(:set_attribute).with('langfuse.user.id', '123') expect(mock_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456') - expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', '["reply_suggestion"]') + expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['reply_suggestion']) + expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.user_id', '123') + expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456') + expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'reply_suggestion') end it 'sets completion message attributes when result contains message' do @@ -253,6 +256,76 @@ RSpec.describe Integrations::LlmInstrumentation do expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', result_data.to_json) end + it 'propagates trace attributes as observation metadata to child tool spans' do + root_span = instance_double(OpenTelemetry::Trace::Span) + tool_span = instance_double(OpenTelemetry::Trace::Span) + tool_instance = test_class.new + allow(root_span).to receive(:set_attribute) + allow(tool_span).to receive(:set_attribute) + allow(instance).to receive(:tracer).and_return(mock_tracer) + allow(tool_instance).to receive(:tracer).and_return(mock_tracer) + allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span) + allow(mock_tracer).to receive(:in_span).with('tool.search').and_yield(tool_span) + + instance.instrument_agent_session(params) do + tool_instance.instrument_tool_call('search', { query: 'test' }) { 'tool result' } + end + + expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.user_id', '123') + expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456') + expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'reply_suggestion') + end + + it 'keeps inherited session metadata for nested service spans with their own feature tag' do + root_span = instance_double(OpenTelemetry::Trace::Span) + nested_span = instance_double(OpenTelemetry::Trace::Span) + nested_instance = test_class.new + nested_params = params.merge(span_name: 'llm.translate_query', conversation_id: nil, feature_name: 'translate_query') + allow(root_span).to receive(:set_attribute) + allow(nested_span).to receive(:set_attribute) + allow(instance).to receive(:tracer).and_return(mock_tracer) + allow(nested_instance).to receive(:tracer).and_return(mock_tracer) + allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span) + allow(mock_tracer).to receive(:in_span).with('llm.translate_query').and_yield(nested_span) + + instance.instrument_agent_session(params) do + nested_instance.instrument_llm_call(nested_params) { 'translated query' } + end + + expect(nested_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456') + expect(nested_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['translate_query']) + expect(nested_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456') + expect(nested_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'translate_query') + end + + it 'propagates session metadata to nested embedding spans' do + root_span = instance_double(OpenTelemetry::Trace::Span) + embedding_span = instance_double(OpenTelemetry::Trace::Span) + embedding_instance = test_class.new + embedding_params = { + span_name: 'llm.captain.embedding', + account_id: 123, + feature_name: 'embedding', + model: 'text-embedding-3-small', + input: 'search result' + } + allow(root_span).to receive(:set_attribute) + allow(embedding_span).to receive(:set_attribute) + allow(instance).to receive(:tracer).and_return(mock_tracer) + allow(embedding_instance).to receive(:tracer).and_return(mock_tracer) + allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span) + allow(mock_tracer).to receive(:in_span).with('llm.captain.embedding').and_yield(embedding_span) + + instance.instrument_agent_session(params) do + embedding_instance.instrument_embedding_call(embedding_params) { [0.1, 0.2, 0.3] } + end + + expect(embedding_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456') + expect(embedding_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['embedding']) + expect(embedding_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456') + expect(embedding_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'embedding') + end + # Regression test for Langfuse double-counting bug. # Setting gen_ai.request.model on parent spans causes Langfuse to classify them as # GENERATIONs instead of SPANs, resulting in cost being counted multiple times From cd9192f7d129a9629ae83908afd119321ea9b8a5 Mon Sep 17 00:00:00 2001 From: tomsideguide Date: Wed, 3 Jun 2026 14:17:49 -0400 Subject: [PATCH 32/45] chore(captain): update Firecrawl to use the v2 API (#14624) ## Description Migrates Firecrawl from the v1 to the v2 API. `Captain::Tools::FirecrawlService` now targets `api.firecrawl.dev/v2`, with the request body updated to match the v2 schema. > Disclosure: I work at Firecrawl. Fixes # (n/a) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Updated `spec/enterprise/services/captain/tools/firecrawl_service_spec.rb` to assert the v2 endpoint and request body. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [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 --------- Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> --- .../captain/tools/firecrawl_service.rb | 11 +++++---- .../captain/tools/firecrawl_service_spec.rb | 23 ++++++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/enterprise/app/services/captain/tools/firecrawl_service.rb b/enterprise/app/services/captain/tools/firecrawl_service.rb index bee7219e8..6797634a2 100644 --- a/enterprise/app/services/captain/tools/firecrawl_service.rb +++ b/enterprise/app/services/captain/tools/firecrawl_service.rb @@ -1,5 +1,5 @@ class Captain::Tools::FirecrawlService - BASE_URL = 'https://api.firecrawl.dev/v1'.freeze + BASE_URL = 'https://api.firecrawl.dev/v2'.freeze FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze def self.configured? @@ -35,10 +35,10 @@ class Captain::Tools::FirecrawlService def crawl_payload(url, webhook_url, crawl_limit) { url: url, - maxDepth: 50, - ignoreSitemap: false, + maxDiscoveryDepth: 50, + sitemap: 'include', limit: crawl_limit, - webhook: webhook_url, + webhook: { url: webhook_url }, scrapeOptions: scrape_options }.to_json end @@ -51,7 +51,8 @@ class Captain::Tools::FirecrawlService { onlyMainContent: true, formats: ['markdown'], - excludeTags: FIRECRAWL_EXCLUDE_TAGS + excludeTags: FIRECRAWL_EXCLUDE_TAGS, + maxAge: 0 } end diff --git a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb index 4d4bc7aaf..9a099fc67 100644 --- a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb +++ b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb @@ -53,14 +53,15 @@ RSpec.describe Captain::Tools::FirecrawlService do let(:expected_payload) do { url: url, - maxDepth: 50, - ignoreSitemap: false, + maxDiscoveryDepth: 50, + sitemap: 'include', limit: crawl_limit, - webhook: webhook_url, + webhook: { url: webhook_url }, scrapeOptions: { onlyMainContent: true, formats: ['markdown'], - excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS + excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS, + maxAge: 0 } }.to_json end @@ -74,7 +75,7 @@ RSpec.describe Captain::Tools::FirecrawlService do context 'when the API call is successful' do before do - stub_request(:post, 'https://api.firecrawl.dev/v1/crawl') + stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') .with( body: expected_payload, headers: expected_headers @@ -85,7 +86,7 @@ RSpec.describe Captain::Tools::FirecrawlService do it 'makes a POST request with correct parameters' do service.perform(url, webhook_url, crawl_limit) - expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl') + expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl') .with( body: expected_payload, headers: expected_headers @@ -95,7 +96,7 @@ RSpec.describe Captain::Tools::FirecrawlService do it 'uses default crawl limit when not specified' do default_payload = expected_payload.gsub(crawl_limit.to_s, '10') - stub_request(:post, 'https://api.firecrawl.dev/v1/crawl') + stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') .with( body: default_payload, headers: expected_headers @@ -104,7 +105,7 @@ RSpec.describe Captain::Tools::FirecrawlService do service.perform(url, webhook_url) - expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl') + expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl') .with( body: default_payload, headers: expected_headers @@ -114,7 +115,7 @@ RSpec.describe Captain::Tools::FirecrawlService do context 'when the API call fails' do before do - stub_request(:post, 'https://api.firecrawl.dev/v1/crawl') + stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') .to_raise(StandardError.new('Connection failed')) end @@ -126,14 +127,14 @@ RSpec.describe Captain::Tools::FirecrawlService do context 'when the API returns an error response' do before do - stub_request(:post, 'https://api.firecrawl.dev/v1/crawl') + stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') .to_return(status: 422, body: '{"error": "Invalid URL"}') end it 'makes the request but does not raise an error' do expect { service.perform(url, webhook_url, crawl_limit) }.not_to raise_error - expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl') + expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl') .with( body: expected_payload, headers: expected_headers From 64c5aeebeeab99f9a8fbd93de5f40d88f241e787 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 4 Jun 2026 06:34:12 -0700 Subject: [PATCH 33/45] feat(help-center): support per-locale portal title, name & header (#14642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2026-06-03 at 2 48 59 PM https://github.com/user-attachments/assets/f5affbd2-7ad4-415a-b376-57c759fc2aaa --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../api/v1/accounts/portals_controller.rb | 7 +- .../public/api/v1/portals_controller.rb | 2 +- .../HelpCenter/LocaleCard/LocaleCard.vue | 5 +- .../Pages/LocalePage/LocaleContentDialog.vue | 98 +++++++++++++++++++ .../Pages/LocalePage/LocaleList.vue | 7 ++ .../dashboard/helper/portalHelper.js | 27 +++-- .../helper/specs/portalHelper.spec.js | 35 ++++--- .../dashboard/i18n/locale/en/helpCenter.json | 18 ++++ app/models/concerns/portal_config_schema.rb | 35 +++++++ app/models/portal.rb | 29 +++++- .../v1/accounts/portals/_portal.json.jbuilder | 1 + app/views/layouts/_portal_head.html.erb | 2 +- app/views/layouts/_portal_scripts.html.erb | 4 + .../public/api/v1/portals/_header.html.erb | 4 +- .../public/api/v1/portals/_hero.html.erb | 8 +- .../api/v1/portals/articles/index.html.erb | 2 +- .../api/v1/portals/articles/show.html.erb | 2 +- .../api/v1/portals/categories/_hero.html.erb | 2 +- .../api/v1/portals/categories/show.html.erb | 4 +- .../documentation_layout/_hero.html.erb | 2 +- .../documentation_layout/_topbar.html.erb | 2 +- .../articles/_meta_head.html.erb | 2 +- .../categories/_meta_head.html.erb | 4 +- .../search/index.html+documentation.erb | 2 +- .../api/v1/portals/search/index.html.erb | 2 +- .../v1/accounts/portals_controller_spec.rb | 3 +- spec/models/portal_spec.rb | 88 +++++++++++++++++ 27 files changed, 347 insertions(+), 50 deletions(-) create mode 100644 app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue create mode 100644 app/models/concerns/portal_config_schema.rb diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 770018e3c..c74c0ecfc 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -80,10 +80,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController :id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, - { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] } + { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }, + { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] } ) end + def locale_translation_keys + params.dig(:portal, :config, :locale_translations)&.keys || [] + end + def live_chat_widget_params permitted_params = params.permit(:inbox_id) return {} unless permitted_params.key?(:inbox_id) diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index 63f44b052..57db11aec 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl layout 'portal' def show - @og_image_url = helpers.set_og_image_url('', @portal.header_text) + @og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale)) end def sitemap diff --git a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue index 8619e5b77..259328bec 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue @@ -53,6 +53,9 @@ const localeMenuLabels = computed(() => ({ 'publish-locale': t( 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE' ), + 'customize-content': t( + 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT' + ), delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'), })); @@ -128,7 +131,7 @@ const handleAction = ({ action, value }) => { diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue new file mode 100644 index 000000000..11d38f2a2 --- /dev/null +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue @@ -0,0 +1,98 @@ + + + diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue index 62c644655..66d389ead 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue @@ -1,5 +1,7 @@ <% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %> + <%= @portal.channel_web_widget.web_widget_script.html_safe %> <% end %> diff --git a/app/views/public/api/v1/portals/_header.html.erb b/app/views/public/api/v1/portals/_header.html.erb index 3db8efa59..189371261 100644 --- a/app/views/public/api/v1/portals/_header.html.erb +++ b/app/views/public/api/v1/portals/_header.html.erb @@ -5,7 +5,7 @@ <% if @portal.logo.present? %> <% end %> - <%= @portal.name %> + <%= @portal.localized_value('name', @locale) %> @@ -106,7 +106,7 @@ <% if @portal.logo.present? %> <% end %> - <%= @portal.name %> + <%= @portal.localized_value('name', @locale) %> diff --git a/app/views/public/api/v1/portals/_hero.html.erb b/app/views/public/api/v1/portals/_hero.html.erb index c1ead0e59..1e2bf119c 100644 --- a/app/views/public/api/v1/portals/_hero.html.erb +++ b/app/views/public/api/v1/portals/_hero.html.erb @@ -1,7 +1,7 @@ <% if !@is_plain_layout_enabled %> <% content_for :head do %> - <%= @portal.display_title %> - + <%= @portal.display_title(@locale) %> + <% if @og_image_url.present? %> @@ -13,9 +13,9 @@
- <%= @portal.name %> + <%= @portal.localized_value('name', @locale) %>

- <%= portal.header_text %> + <%= portal.localized_value('header_text', @locale) %>

<%= I18n.t('public_portal.hero.sub_title') %>

diff --git a/app/views/public/api/v1/portals/articles/index.html.erb b/app/views/public/api/v1/portals/articles/index.html.erb index d040bbc24..e4dc9aa87 100644 --- a/app/views/public/api/v1/portals/articles/index.html.erb +++ b/app/views/public/api/v1/portals/articles/index.html.erb @@ -6,7 +6,7 @@ class="leading-8 text-slate-800 hover:underline" href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>" > - <%= @portal.name %> <%= I18n.t('public_portal.common.home') %> + <%= @portal.localized_value('name', @locale) %> <%= I18n.t('public_portal.common.home') %> / / diff --git a/app/views/public/api/v1/portals/articles/show.html.erb b/app/views/public/api/v1/portals/articles/show.html.erb index 784817c9c..e35b19009 100644 --- a/app/views/public/api/v1/portals/articles/show.html.erb +++ b/app/views/public/api/v1/portals/articles/show.html.erb @@ -1,5 +1,5 @@ <% content_for :head do %> - <%= @article.title %> | <%= @portal.display_title %> + <%= @article.title %> | <%= @portal.display_title(@locale) %> <% if @article.meta["title"].present? %> "> "> diff --git a/app/views/public/api/v1/portals/categories/_hero.html.erb b/app/views/public/api/v1/portals/categories/_hero.html.erb index 0c179338d..1b481617e 100644 --- a/app/views/public/api/v1/portals/categories/_hero.html.erb +++ b/app/views/public/api/v1/portals/categories/_hero.html.erb @@ -1,7 +1,7 @@
-

<%= portal.header_text %>

+

<%= portal.localized_value('header_text', @locale) %>

<%= I18n.t('public_portal.hero.sub_title') %>

diff --git a/app/views/public/api/v1/portals/categories/show.html.erb b/app/views/public/api/v1/portals/categories/show.html.erb index 6657559d0..e7179db73 100644 --- a/app/views/public/api/v1/portals/categories/show.html.erb +++ b/app/views/public/api/v1/portals/categories/show.html.erb @@ -1,6 +1,6 @@ <% content_for :head do %> - <%= @category.name %> | <%= @portal.display_title %> - + <%= @category.name %> | <%= @portal.display_title(@locale) %> + <% if @category.description.present? %> diff --git a/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb index c7df0517d..af80d46ac 100644 --- a/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb @@ -5,7 +5,7 @@

- <%= portal.header_text.presence || 'How can we help?' %> + <%= portal.localized_value('header_text', @locale).presence || 'How can we help?' %>

<%= I18n.t('public_portal.hero.sub_title') %> diff --git a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb index 46d6f3558..00f408d70 100644 --- a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb @@ -11,7 +11,7 @@ <% if portal.logo.present? %> <% end %> - <%= portal.name %> + <%= portal.localized_value('name', locale) %> diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb index a236722ae..aeed7ff6c 100644 --- a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb @@ -1,4 +1,4 @@ -<%= article.title %> | <%= portal.display_title %> +<%= article.title %> | <%= portal.display_title(article.locale) %> <% if article.meta["title"].present? %> "> "> diff --git a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb index 7c8dec8d2..ac36496df 100644 --- a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb +++ b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb @@ -1,5 +1,5 @@ -<%= category.name %> | <%= portal.display_title %> - +<%= category.name %> | <%= portal.display_title(category.locale) %> + <% if category.description.present? %> diff --git a/app/views/public/api/v1/portals/search/index.html+documentation.erb b/app/views/public/api/v1/portals/search/index.html+documentation.erb index 8577a5f4e..f77517618 100644 --- a/app/views/public/api/v1/portals/search/index.html+documentation.erb +++ b/app/views/public/api/v1/portals/search/index.html+documentation.erb @@ -1,5 +1,5 @@ <% content_for :head do %> - <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %> + <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %> <% end %>

diff --git a/app/views/public/api/v1/portals/search/index.html.erb b/app/views/public/api/v1/portals/search/index.html.erb index 82c29775f..b4a1e77ed 100644 --- a/app/views/public/api/v1/portals/search/index.html.erb +++ b/app/views/public/api/v1/portals/search/index.html.erb @@ -1,5 +1,5 @@ <% content_for :head do %> - <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %> + <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %> <% end %> <% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %> diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb index 860791c0e..ccb5d7449 100644 --- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb @@ -173,7 +173,8 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do ], 'default_locale' => 'en', 'layout' => 'classic', - 'social_profiles' => {} + 'social_profiles' => {}, + 'locale_translations' => {} } ) end diff --git a/spec/models/portal_spec.rb b/spec/models/portal_spec.rb index 38d9a7da6..c71a458fd 100644 --- a/spec/models/portal_spec.rb +++ b/spec/models/portal_spec.rb @@ -60,6 +60,94 @@ RSpec.describe Portal do portal.update(custom_domain: '') expect(portal.custom_domain).to be_nil end + + context 'with locale_translations' do + it 'allows valid locale translations' do + portal.update(config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro', 'page_title' => 'Título', 'header_text' => 'Hola' } } }) + + expect(portal).to be_valid + end + + it 'rejects unknown fields within a locale translation' do + portal.update(config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'tagline' => 'nope' } } }) + + expect(portal).not_to be_valid + end + + it 'retains a locale override after it becomes the default so it can still be edited' do + portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro' } } }) + + portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' }) + + expect(portal.config['locale_translations']).to eq({ 'es' => { 'name' => 'Centro' } }) + end + end + end + end + + describe '#localized_value' do + let!(:account) { create(:account) } + let!(:portal) do + create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme', + config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } }) + end + + it 'returns the override for the requested locale' do + expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda') + end + + it 'falls back to the base column when the locale has no override for the field' do + expect(portal.localized_value('page_title', 'es')).to eq('Help Center | Acme') + end + + it 'falls back to the base column when the locale has no overrides at all' do + expect(portal.localized_value('name', 'fr')).to eq('Help Center') + end + + it 'keeps serving the override for a locale that has become the default' do + portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' }) + + expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda') + end + + it "inherits the default locale's override for a locale without its own" do + portal.update!(config: { allowed_locales: %w[en es fr], default_locale: 'es' }) + + expect(portal.localized_value('name', 'fr')).to eq('Centro de ayuda') + end + + it 'uses the default locale when no locale is given' do + expect(portal.localized_value('name')).to eq('Help Center') + end + end + + describe '#display_title' do + let!(:account) { create(:account) } + + it 'prefers the localized page_title' do + portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme', + config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'page_title' => 'Centro | Acme' } } }) + + expect(portal.display_title('es')).to eq('Centro | Acme') + end + + it 'falls back to the localized name when no page_title is set' do + portal = create(:portal, account_id: account.id, name: 'Help Center', + config: { allowed_locales: %w[en es], default_locale: 'en', + locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } }) + + expect(portal.display_title('es')).to eq('Centro de ayuda') + end + + it 'uses the base values for the default locale' do + portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme') + + expect(portal.display_title).to eq('Help Center | Acme') end end end From ec43975f3f6ec64fb63b752acba571e740d57693 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:25:32 +0530 Subject: [PATCH 34/45] chore: use square avatars (#14656) --- .../components-next/Companies/CompaniesCard/CompaniesCard.vue | 3 +-- .../Companies/CompanyDetail/CompanyProfileCard.vue | 1 - .../components-next/Contacts/ContactsCard/ContactsCard.vue | 3 +-- .../dashboard/components-next/sidebar/SidebarProfileMenu.vue | 1 - .../components/widgets/conversation/ConversationHeader.vue | 1 - .../routes/dashboard/conversation/contact/ContactInfo.vue | 1 - 6 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue index fe4385bbb..d81b997c5 100644 --- a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue +++ b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue @@ -46,9 +46,8 @@ const formattedLastActivityAt = computed(() => { :src="avatarSource" class="shrink-0" :name="name" - :size="48" + :size="42" hide-offline-status - rounded-full />
diff --git a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue index 1ffbf0c18..7615328ca 100644 --- a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue +++ b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue @@ -141,7 +141,6 @@ const handleUpdateCompany = async () => { :src="avatarSource" :size="72" :allow-upload="!isAvatarBusy" - rounded-full hide-offline-status @upload="handleAvatarUpload" @delete="handleAvatarDelete" diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index ba887b46f..50af6f0c0 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -124,10 +124,9 @@ const handleAvatarHover = isHovered => {