diff --git a/.env.example b/.env.example index de671599c..9031fc08a 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,7 @@ MAILER_INBOUND_EMAIL_DOMAIN= # mandrill for Mandrill # postmark for Postmark # sendgrid for Sendgrid +# ses for Amazon SES RAILS_INBOUND_EMAIL_SERVICE= # Use one of the following based on the email ingress service # Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html @@ -114,6 +115,10 @@ RAILS_INBOUND_EMAIL_PASSWORD= MAILGUN_INGRESS_SIGNING_KEY= MANDRILL_INGRESS_API_KEY= +# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name) +# Configure only if the rails_inbound_email_service = ses +ACTION_MAILBOX_SES_SNS_TOPIC= + # Creating Your Inbound Webhook Instructions for Postmark and Sendgrid: # Inbound webhook URL format: # https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails @@ -256,6 +261,8 @@ AZURE_APP_SECRET= ## Change these values to fine tune performance # control the concurrency setting of sidekiq # SIDEKIQ_CONCURRENCY=10 +# Enable verbose logging each time a job is dequeued in Sidekiq +# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false # AI powered features diff --git a/.github/workflows/test_docker_build.yml b/.github/workflows/test_docker_build.yml index 460c4ba1f..b27d90408 100644 --- a/.github/workflows/test_docker_build.yml +++ b/.github/workflows/test_docker_build.yml @@ -36,5 +36,5 @@ jobs: platforms: ${{ matrix.platform }} push: false load: false - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} diff --git a/.rubocop.yml b/.rubocop.yml index e30a71ee9..ea688792b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -23,7 +23,7 @@ Metrics/MethodLength: - 'enterprise/lib/captain/agent.rb' RSpec/ExampleLength: - Max: 25 + Max: 50 Style/Documentation: Enabled: false @@ -336,4 +336,4 @@ FactoryBot/RedundantFactoryOption: Enabled: false FactoryBot/FactoryAssociationWithStrategy: - Enabled: false \ No newline at end of file + Enabled: false diff --git a/Gemfile b/Gemfile index 18442e3b0..31ecf5b3c 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ gem 'telephone_number' gem 'time_diff' gem 'tzinfo-data' gem 'valid_email2' +gem 'email-provider-info' # compress javascript config.assets.js_compressor gem 'uglifier' ##-- used for single column multiple binary flags in notification settings/feature flagging --## @@ -54,6 +55,9 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'image_processing' +##-- for actionmailbox --## +gem 'aws-actionmailbox-ses', '~> 0' + ##-- gems for database --# gem 'groupdate' gem 'pg' diff --git a/Gemfile.lock b/Gemfile.lock index 80a78c6b6..6af8c0e7c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -136,9 +136,13 @@ GEM audited (5.4.1) activerecord (>= 5.0, < 7.7) activesupport (>= 5.0, < 7.7) + aws-actionmailbox-ses (0.1.0) + actionmailbox (>= 7.1.0) + aws-sdk-s3 (~> 1, >= 1.123.0) + aws-sdk-sns (~> 1, >= 1.61.0) aws-eventstream (1.2.0) aws-partitions (1.760.0) - aws-sdk-core (3.171.1) + aws-sdk-core (3.188.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) @@ -146,10 +150,13 @@ GEM aws-sdk-kms (1.64.0) aws-sdk-core (~> 3, >= 3.165.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.122.0) - aws-sdk-core (~> 3, >= 3.165.0) + aws-sdk-s3 (1.126.0) + aws-sdk-core (~> 3, >= 3.174.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.4) + aws-sdk-sns (1.70.0) + aws-sdk-core (~> 3, >= 3.188.0) + aws-sigv4 (~> 1.1) aws-sigv4 (1.5.2) aws-eventstream (~> 1, >= 1.0.2) barnes (0.0.9) @@ -270,6 +277,7 @@ GEM concurrent-ruby (~> 1.0) http (>= 3.0) ruby2_keywords + email-provider-info (0.0.1) email_reply_trimmer (0.1.13) erubi (1.13.0) et-orbi (1.2.11) @@ -594,7 +602,7 @@ GEM oj (3.16.10) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.3) + omniauth (2.1.4) hashie (>= 3.4.6) logger rack (>= 2.2.3) @@ -653,7 +661,7 @@ GEM rack (>= 2.0.0) rack-mini-profiler (3.2.0) rack (>= 1.2.0) - rack-protection (4.1.1) + rack-protection (4.2.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) @@ -994,6 +1002,7 @@ DEPENDENCIES annotate attr_extras audited (~> 5.4, >= 5.4.1) + aws-actionmailbox-ses (~> 0) aws-sdk-s3 azure-storage-blob! barnes @@ -1016,6 +1025,7 @@ DEPENDENCIES dotenv-rails (>= 3.0.0) down elastic-apm + email-provider-info email_reply_trimmer facebook-messenger factory_bot_rails (>= 6.4.3) diff --git a/app.json b/app.json index 08e725c8e..91fb0fbd5 100644 --- a/app.json +++ b/app.json @@ -36,6 +36,10 @@ "REDIS_OPENSSL_VERIFY_MODE":{ "description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues", "value": "none" + }, + "NODE_OPTIONS": { + "description": "Increase V8 heap for Vite build to avoid OOM", + "value": "--max-old-space-size=4096" } }, "formation": { diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 12a74ed9c..af31a0728 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -1,5 +1,8 @@ class Messages::MessageBuilder include ::FileTypeHelper + include ::EmailHelper + include ::DataHelper + attr_reader :message def initialize(user, conversation, params) @@ -23,7 +26,7 @@ class Messages::MessageBuilder process_emails # When the message has no quoted content, it will just be rendered as a regular message # The frontend is equipped to handle this case - process_email_content if @account.feature_enabled?(:quoted_email_reply) + process_email_content @message.save! @message end @@ -38,30 +41,12 @@ class Messages::MessageBuilder params = convert_to_hash(@params) content_attributes = params.fetch(:content_attributes, {}) - return parse_json(content_attributes) if content_attributes.is_a?(String) + return safe_parse_json(content_attributes) if content_attributes.is_a?(String) return content_attributes if content_attributes.is_a?(Hash) {} end - # Converts the given object to a hash. - # If it's an instance of ActionController::Parameters, converts it to an unsafe hash. - # Otherwise, returns the object as-is. - def convert_to_hash(obj) - return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters) - - obj - end - - # Attempts to parse a string as JSON. - # If successful, returns the parsed hash with symbolized names. - # If unsuccessful, returns nil. - def parse_json(content) - JSON.parse(content, symbolize_names: true) - rescue JSON::ParserError - {} - end - def process_attachments return if @attachments.blank? @@ -110,12 +95,6 @@ class Messages::MessageBuilder email_string.gsub(/\s+/, '').split(',') end - def validate_email_addresses(all_emails) - all_emails&.each do |email| - raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP) - end - end - def message_type if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' raise StandardError, 'Incoming messages are only allowed in Api inboxes' @@ -178,14 +157,17 @@ class Messages::MessageBuilder email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {}) normalized_content = normalize_email_body(@message.content) + # Process liquid templates in normalized content with code block protection + processed_content = process_liquid_in_email_body(normalized_content) + # Use custom HTML content if provided, otherwise generate from message content email_attributes[:html_content] = if custom_email_content_provided? build_custom_html_content else - build_html_content(normalized_content) + build_html_content(processed_content) end - email_attributes[:text_content] = build_text_content(normalized_content) + email_attributes[:text_content] = build_text_content(processed_content) email_attributes end @@ -204,22 +186,6 @@ class Messages::MessageBuilder text_content end - def ensure_indifferent_access(hash) - return {} if hash.blank? - - hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash - end - - def normalize_email_body(content) - content.to_s.gsub("\r\n", "\n") - end - - def render_email_html(content) - return '' if content.blank? - - ChatwootMarkdownRenderer.new(content).render_message.to_s - end - def custom_email_content_provided? @params[:email_html_content].present? end @@ -232,4 +198,27 @@ class Messages::MessageBuilder html_content end + + # Liquid processing methods for email content + def process_liquid_in_email_body(content) + return content if content.blank? + return content unless should_process_liquid? + + # Protect code blocks from liquid processing + modified_content = modified_liquid_content(content) + template = Liquid::Template.parse(modified_content) + template.render(drops_with_sender) + rescue Liquid::Error + content + end + + def should_process_liquid? + @message_type == 'outgoing' || @message_type == 'template' + end + + def drops_with_sender + message_drops(@conversation).merge({ + 'agent' => UserDrop.new(sender) + }) + end end diff --git a/app/builders/v2/reports/base_summary_builder.rb b/app/builders/v2/reports/base_summary_builder.rb index 4de65926d..d4a9e7c0b 100644 --- a/app/builders/v2/reports/base_summary_builder.rb +++ b/app/builders/v2/reports/base_summary_builder.rb @@ -10,10 +10,28 @@ class V2::Reports::BaseSummaryBuilder def load_data @conversations_count = fetch_conversations_count - @resolved_count = fetch_resolved_count - @avg_resolution_time = fetch_average_time('conversation_resolved') - @avg_first_response_time = fetch_average_time('first_response') - @avg_reply_time = fetch_average_time('reply_time') + load_reporting_events_data + end + + def load_reporting_events_data + # Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id') + index_key = group_by_key.to_s.split('.').last + + results = reporting_events + .select( + "#{group_by_key} as #{index_key}", + "COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count", + "AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time", + "AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time", + "AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time" + ) + .group(group_by_key) + .index_by { |record| record.public_send(index_key) } + + @resolved_count = results.transform_values(&:resolved_count) + @avg_resolution_time = results.transform_values(&:avg_resolution_time) + @avg_first_response_time = results.transform_values(&:avg_first_response_time) + @avg_reply_time = results.transform_values(&:avg_reply_time) end def reporting_events @@ -24,14 +42,6 @@ class V2::Reports::BaseSummaryBuilder # Override this method end - def fetch_average_time(event_name) - get_grouped_average(reporting_events.where(name: event_name)) - end - - def fetch_resolved_count - reporting_events.where(name: 'conversation_resolved').group(group_by_key).count - end - def group_by_key # Override this method end @@ -40,10 +50,6 @@ class V2::Reports::BaseSummaryBuilder # Override this method end - def get_grouped_average(events) - events.group(group_by_key).average(average_value_key) - end - def average_value_key ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value end diff --git a/app/builders/v2/reports/inbox_summary_builder.rb b/app/builders/v2/reports/inbox_summary_builder.rb index e27385856..935afeb82 100644 --- a/app/builders/v2/reports/inbox_summary_builder.rb +++ b/app/builders/v2/reports/inbox_summary_builder.rb @@ -13,10 +13,7 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder def load_data @conversations_count = fetch_conversations_count - @resolved_count = fetch_resolved_count - @avg_resolution_time = fetch_average_time('conversation_resolved') - @avg_first_response_time = fetch_average_time('first_response') - @avg_reply_time = fetch_average_time('reply_time') + load_reporting_events_data end def fetch_conversations_count diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index 64c35d33d..c2f919659 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController before_action :agent_bot, except: [:index, :create] def index - @agent_bots = AgentBot.where(account_id: [nil, Current.account.id]) + @agent_bots = AgentBot.accessible_to(Current.account) end def show; end @@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController private def agent_bot - @agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show' + @agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show' @agent_bot ||= Current.account.agent_bots.find(params[:id]) end diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index da2be2312..8a6fd61f8 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -22,9 +22,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController def edit; end def create - @article = @portal.articles.create!(article_params) + params_with_defaults = article_params + params_with_defaults[:status] ||= :draft + @article = @portal.articles.create!(params_with_defaults) @article.associate_root_article(article_params[:associated_article_id]) - @article.draft! render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? end diff --git a/app/controllers/api/v1/accounts/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/bulk_actions_controller.rb index 34db47861..222c66714 100644 --- a/app/controllers/api/v1/accounts/bulk_actions_controller.rb +++ b/app/controllers/api/v1/accounts/bulk_actions_controller.rb @@ -1,13 +1,12 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController - before_action :type_matches? - def create - if type_matches? - ::BulkActionsJob.perform_later( - account: @current_account, - user: current_user, - params: permitted_params - ) + case normalized_type + when 'Conversation' + enqueue_conversation_job + head :ok + when 'Contact' + check_authorization_for_contact_action + enqueue_contact_job head :ok else render json: { success: false }, status: :unprocessable_entity @@ -16,11 +15,54 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll private - def type_matches? - ['Conversation'].include?(params[:type]) + def normalized_type + params[:type].to_s.camelize end - def permitted_params - params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []]) + def enqueue_conversation_job + ::BulkActionsJob.perform_later( + account: @current_account, + user: current_user, + params: conversation_params + ) + end + + def enqueue_contact_job + Contacts::BulkActionJob.perform_later( + @current_account.id, + current_user.id, + contact_params + ) + end + + def delete_contact_action? + params[:action_name] == 'delete' + end + + def check_authorization_for_contact_action + authorize(Contact, :destroy?) if delete_contact_action? + end + + def conversation_params + # TODO: Align conversation payloads with the `{ action_name, action_attributes }` + # and then remove this method in favor of a common params method. + base = params.permit( + :snoozed_until, + fields: [:status, :assignee_id, :team_id] + ) + append_common_bulk_attributes(base) + end + + def contact_params + # TODO: remove this method in favor of a common params method. + # once legacy conversation payloads are migrated. + append_common_bulk_attributes({}) + end + + def append_common_bulk_attributes(base_params) + # NOTE: Conversation payloads historically diverged per action. Going forward we + # want all objects to share a common contract: `{ action_name, action_attributes }` + common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []]) + base_params.merge(common) end end diff --git a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb index 1fb2095e3..49806e97c 100644 --- a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController # assigns agent/team to a conversation def create - if params.key?(:assignee_id) + if params.key?(:assignee_id) || agent_bot_assignment? set_agent elsif params.key?(:team_id) set_team @@ -13,17 +13,23 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account private def set_agent - @agent = Current.account.users.find_by(id: params[:assignee_id]) - @conversation.assignee = @agent - @conversation.save! - render_agent + resource = Conversations::AssignmentService.new( + conversation: @conversation, + assignee_id: params[:assignee_id], + assignee_type: params[:assignee_type] + ).perform + + render_agent(resource) end - def render_agent - if @agent.nil? - render json: nil + def render_agent(resource) + case resource + when User + render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource } + when AgentBot + render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource } else - render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent } + render json: nil end end @@ -32,4 +38,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account @conversation.update!(team: @team) render json: @team end + + def agent_bot_assignment? + params[:assignee_type].to_s == 'AgentBot' + end end diff --git a/app/controllers/api/v1/accounts/conversations/base_controller.rb b/app/controllers/api/v1/accounts/conversations/base_controller.rb index 500c7772f..223530e27 100644 --- a/app/controllers/api/v1/accounts/conversations/base_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/base_controller.rb @@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base def conversation @conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id]) - authorize @conversation.inbox, :show? + authorize @conversation, :show? end end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index e27869d82..4301eaa4a 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -160,7 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def conversation @conversation ||= Current.account.conversations.find_by!(display_id: params[:id]) - authorize @conversation.inbox, :show? + authorize @conversation, :show? end def inbox diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb index c5f795d34..845caab5e 100644 --- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -22,7 +22,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC private def authorize_request - authorize @conversation.inbox, :show? + authorize @conversation, :show? end def render_response(response) diff --git a/app/controllers/api/v1/accounts/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb index 7ea257ed2..9f8e94821 100644 --- a/app/controllers/api/v1/accounts/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController private def webhook_params - params.require(:webhook).permit(:inbox_id, :url, subscriptions: []) + params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: []) end def fetch_webhook diff --git a/app/controllers/api/v1/widget/configs_controller.rb b/app/controllers/api/v1/widget/configs_controller.rb index ecbddd905..458d0486c 100644 --- a/app/controllers/api/v1/widget/configs_controller.rb +++ b/app/controllers/api/v1/widget/configs_controller.rb @@ -9,7 +9,13 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController private def set_global_config - @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME') + @global_config = GlobalConfig.get( + 'LOGO_THUMBNAIL', + 'BRAND_NAME', + 'WIDGET_BRAND_URL', + 'MAXIMUM_FILE_UPLOAD_SIZE', + 'INSTALLATION_NAME' + ) end def set_contact diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index 9b0f9021f..338b290da 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -14,6 +14,7 @@ module AccessTokenAuthHelper ensure_access_token render_unauthorized('Invalid Access Token') && return if @access_token.blank? + # NOTE: This ensures that current_user is set and available for the rest of the controller actions @resource = @access_token.owner Current.user = @resource if allowed_current_user_type?(@resource) end diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb index 3baf9ee1e..ea36a48f2 100644 --- a/app/controllers/concerns/ensure_current_account_helper.rb +++ b/app/controllers/concerns/ensure_current_account_helper.rb @@ -25,6 +25,9 @@ module EnsureCurrentAccountHelper end def account_accessible_for_bot?(account) - render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) + return if @resource.account_id == account.id + return if @resource.agent_bot_inboxes.find_by(account_id: account.id) + + render_unauthorized('Bot is not authorized to access this account') end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d81b4c9da..5003c4c70 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,6 +1,31 @@ class DashboardController < ActionController::Base include SwitchLocale + GLOBAL_CONFIG_KEYS = %w[ + LOGO + LOGO_DARK + LOGO_THUMBNAIL + INSTALLATION_NAME + WIDGET_BRAND_URL + TERMS_URL + BRAND_URL + BRAND_NAME + PRIVACY_URL + DISPLAY_MANIFEST + CREATE_NEW_ACCOUNT_FROM_DASHBOARD + CHATWOOT_INBOX_TOKEN + API_CHANNEL_NAME + API_CHANNEL_THUMBNAIL + ANALYTICS_TOKEN + DIRECT_UPLOADS_ENABLED + MAXIMUM_FILE_UPLOAD_SIZE + HCAPTCHA_SITE_KEY + LOGOUT_REDIRECT_LINK + DISABLE_USER_PROFILE_UPDATE + DEPLOYMENT_ENV + INSTALLATION_PRICING_PLAN + ].freeze + before_action :set_application_pack before_action :set_global_config before_action :set_dashboard_scripts @@ -19,25 +44,7 @@ class DashboardController < ActionController::Base end def set_global_config - @global_config = GlobalConfig.get( - 'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL', - 'INSTALLATION_NAME', - 'WIDGET_BRAND_URL', 'TERMS_URL', - 'BRAND_URL', 'BRAND_NAME', - 'PRIVACY_URL', - 'DISPLAY_MANIFEST', - 'CREATE_NEW_ACCOUNT_FROM_DASHBOARD', - 'CHATWOOT_INBOX_TOKEN', - 'API_CHANNEL_NAME', - 'API_CHANNEL_THUMBNAIL', - 'ANALYTICS_TOKEN', - 'DIRECT_UPLOADS_ENABLED', - 'HCAPTCHA_SITE_KEY', - 'LOGOUT_REDIRECT_LINK', - 'DISABLE_USER_PROFILE_UPDATE', - 'DEPLOYMENT_ENV', - 'INSTALLATION_PRICING_PLAN' - ).merge(app_config) + @global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config) end def set_dashboard_scripts @@ -71,10 +78,18 @@ class DashboardController < ActionController::Base WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), - GIT_SHA: GIT_HASH + GIT_SHA: GIT_HASH, + ALLOWED_LOGIN_METHODS: allowed_login_methods } end + def allowed_login_methods + methods = ['email'] + methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false' + methods << 'saml' if ChatwootHub.pricing_plan != 'community' && GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s != 'false' + methods + end + def set_application_pack @application_pack = if request.path.include?('/auth') || request.path.include?('/login') 'v3app' diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 5cf158b98..1a9539bb6 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -15,14 +15,20 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController end def create + errors = [] params['app_config'].each do |key, value| next unless @allowed_configs.include?(key) i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false) i.value = value - i.save! + errors.concat(i.errors.full_messages) unless i.save + end + + if errors.any? + redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ') + else + redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully" end - redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully" end private @@ -42,10 +48,13 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], - 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI] + 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN] } - @allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]) + @allowed_configs = mapping.fetch( + @config, + %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE] + ) end end diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 9a6a376f7..7f45ce636 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -14,7 +14,14 @@ class WidgetsController < ActionController::Base private def set_global_config - @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME') + @global_config = GlobalConfig.get( + 'LOGO_THUMBNAIL', + 'BRAND_NAME', + 'WIDGET_BRAND_URL', + 'DIRECT_UPLOADS_ENABLED', + 'MAXIMUM_FILE_UPLOAD_SIZE', + 'INSTALLATION_NAME' + ) end def set_web_widget diff --git a/app/helpers/data_helper.rb b/app/helpers/data_helper.rb new file mode 100644 index 000000000..66f5a9508 --- /dev/null +++ b/app/helpers/data_helper.rb @@ -0,0 +1,24 @@ +# Provides utility methods for data transformation, hash manipulation, and JSON parsing. +# This module contains helper methods for converting between different data types, +# normalizing hashes, and safely handling JSON operations. +module DataHelper + # Ensures a hash supports indifferent access (string or symbol keys). + # Returns an empty hash if the input is blank. + def ensure_indifferent_access(hash) + return {} if hash.blank? + + hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash + end + + def convert_to_hash(obj) + return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters) + + obj + end + + def safe_parse_json(content) + JSON.parse(content, symbolize_names: true) + rescue JSON::ParserError + {} + end +end diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb index 05b6a53e3..fcc8b463d 100644 --- a/app/helpers/email_helper.rb +++ b/app/helpers/email_helper.rb @@ -4,6 +4,19 @@ module EmailHelper domain.split('.').first end + def render_email_html(content) + return '' if content.blank? + + ChatwootMarkdownRenderer.new(content).render_message.to_s + end + + # Raise a standard error if any email address is invalid + def validate_email_addresses(emails_to_test) + emails_to_test&.each do |email| + raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP) + end + end + # ref: https://www.rfc-editor.org/rfc/rfc5233.html # This is not a mandatory requirement for email addresses, but it is a common practice. # john+test@xyc.com is the same as john@xyc.com @@ -21,6 +34,10 @@ module EmailHelper end end + def normalize_email_body(content) + content.to_s.gsub("\r\n", "\n") + end + def modified_liquid_content(email) # This regex is used to match the code blocks in the content # We don't want to process liquid in code blocks @@ -29,7 +46,10 @@ module EmailHelper def message_drops(conversation) { - 'contact' => ContactDrop.new(conversation.contact) + 'contact' => ContactDrop.new(conversation.contact), + 'conversation' => ConversationDrop.new(conversation), + 'inbox' => InboxDrop.new(conversation.inbox), + 'account' => AccountDrop.new(conversation.account) } end end diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index f49004e79..b05c603cd 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -9,6 +9,13 @@ captain: icon: 'icon-captain' config_key: 'captain' enterprise: true +saml: + name: 'SAML SSO' + description: 'Configuration for controlling SAML Single Sign-On availability' + enabled: <%= ChatwootApp.enterprise? %> + icon: 'icon-lock-line' + config_key: 'saml' + enterprise: true custom_branding: name: 'Custom Branding' description: 'Apply your own branding to this installation.' diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 0fcb8c9fe..a63cb1a90 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,6 +1,5 @@ + + diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue new file mode 100644 index 000000000..f0dc4255f --- /dev/null +++ b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue @@ -0,0 +1,55 @@ + + + diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue new file mode 100644 index 000000000..768123b9f --- /dev/null +++ b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue @@ -0,0 +1,116 @@ + + + diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue b/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue new file mode 100644 index 000000000..430a2b835 --- /dev/null +++ b/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue index 47b779b61..041f06410 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue @@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core'; import Button from 'dashboard/components-next/button/Button.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; +import Policy from 'dashboard/components/policy.vue'; defineProps({ selectedContact: { @@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => { diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index 0e893b767..12bed151d 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue'; import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue'; +import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import countries from 'shared/constants/countries'; const props = defineProps({ @@ -20,9 +21,17 @@ const props = defineProps({ availabilityStatus: { type: String, default: null }, isExpanded: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false }, + selectable: { type: Boolean, default: false }, + isSelected: { type: Boolean, default: false }, }); -const emit = defineEmits(['toggle', 'updateContact', 'showContact']); +const emit = defineEmits([ + 'toggle', + 'updateContact', + 'showContact', + 'select', + 'avatarHover', +]); const { t } = useI18n(); @@ -88,111 +97,148 @@ const onClickExpand = () => { }; const onClickViewDetails = () => emit('showContact', props.id); + +const toggleSelect = checked => { + emit('select', checked); +}; + +const handleAvatarHover = isHovered => { + emit('avatarHover', isHovered); +}; diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue index aec21a976..b7014c42f 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue @@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue'; import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; +import Policy from 'dashboard/components/policy.vue'; const props = defineProps({ selectedContact: { @@ -174,27 +175,29 @@ const handleAvatarDelete = async () => { @click="updateContact" /> -
-
-
- {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} -
- - {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} - + +
+
+
+ {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} +
+ + {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} + +
+
-
- +
diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue index 7acc2ff55..72bc2c7dd 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue @@ -10,7 +10,15 @@ import { } from 'shared/helpers/CustomErrors'; import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue'; -defineProps({ contacts: { type: Array, required: true } }); +const props = defineProps({ + contacts: { type: Array, required: true }, + selectedContactIds: { + type: Array, + default: () => [], + }, +}); + +const emit = defineEmits(['toggleContact']); const { t } = useI18n(); const store = useStore(); @@ -20,6 +28,9 @@ const route = useRoute(); const uiFlags = useMapGetter('contacts/getUIFlags'); const isUpdating = computed(() => uiFlags.value.isUpdating); const expandedCardId = ref(null); +const hoveredAvatarId = ref(null); + +const selectedIdsSet = computed(() => new Set(props.selectedContactIds || [])); const updateContact = async updatedData => { try { @@ -58,25 +69,43 @@ const onClickViewDetails = async id => { const toggleExpanded = id => { expandedCardId.value = expandedCardId.value === id ? null : id; }; + +const isSelected = id => selectedIdsSet.value.has(id); + +const shouldShowSelection = id => { + return hoveredAvatarId.value === id || isSelected(id); +}; + +const handleSelect = (id, value) => { + emit('toggleContact', { id, value }); +}; + +const handleAvatarHover = (id, isHovered) => { + hoveredAvatarId.value = isHovered ? id : null; +}; diff --git a/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue b/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue index 1ee969275..5e96df3c6 100644 --- a/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue +++ b/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue @@ -1,5 +1,6 @@ diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index a2f139bdc..67936fa59 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -21,6 +21,10 @@ const props = defineProps({ enableCannedResponses: { type: Boolean, default: true }, enabledMenuOptions: { type: Array, default: () => [] }, enableCaptainTools: { type: Boolean, default: false }, + signature: { type: String, default: '' }, + allowSignature: { type: Boolean, default: false }, + sendWithSignature: { type: Boolean, default: false }, + channelType: { type: String, default: '' }, }); const emit = defineEmits(['update:modelValue']); @@ -100,6 +104,10 @@ watch( :enable-canned-responses="enableCannedResponses" :enabled-menu-options="enabledMenuOptions" :enable-captain-tools="enableCaptainTools" + :signature="signature" + :allow-signature="allowSignature" + :send-with-signature="sendWithSignature" + :channel-type="channelType" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/components-next/EmptyStateLayout.vue b/app/javascript/dashboard/components-next/EmptyStateLayout.vue index f2744dde5..39eda2d85 100644 --- a/app/javascript/dashboard/components-next/EmptyStateLayout.vue +++ b/app/javascript/dashboard/components-next/EmptyStateLayout.vue @@ -14,6 +14,10 @@ defineProps({ type: Array, default: () => [], }, + showBackdrop: { + type: Boolean, + default: true, + }, }); @@ -25,14 +29,24 @@ defineProps({ class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]" >
-
+

{{ subtitle }} diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue index 2d8b219f7..bec54593f 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue @@ -14,6 +14,7 @@ import { } from 'dashboard/helper/portalHelper'; import wootConstants from 'dashboard/constants/globals'; +import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; @@ -140,11 +141,12 @@ const updateArticleStatus = async ({ value }) => { :disabled="!articleId" @click="previewArticle" /> -

+
-

+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index 773ebe315..92c5850de 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -92,7 +92,6 @@ const setSignature = () => { const toggleMessageSignature = () => { setSignatureFlagForInbox(props.channelType, !sendWithSignature.value); - setSignature(); }; // Added this watch to dynamically set signature on target inbox change. diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 4d6d41dac..a02d6d495 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -199,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => { state.attachedFiles = []; }; -const removeTargetInbox = value => { - v$.value.$reset(); - // Remove the signature from message content - // Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp) - if (props.sendWithSignature) { - const signatureToRemove = inboxTypes.value.isEmailOrWebWidget - ? props.messageSignature - : extractTextFromMarkdown(props.messageSignature); +const removeSignatureFromMessage = () => { + // Always remove the signature from message content when inbox/contact is removed + // to ensure no leftover signature content remains + const signatureToRemove = inboxTypes.value.isEmailOrWebWidget + ? props.messageSignature + : extractTextFromMarkdown(props.messageSignature); + if (signatureToRemove) { state.message = removeSignature(state.message, signatureToRemove); } +}; + +const removeTargetInbox = value => { + v$.value.$reset(); + removeSignatureFromMessage(); emit('updateTargetInbox', value); state.attachedFiles = []; }; @@ -216,6 +220,7 @@ const removeTargetInbox = value => { const clearSelectedContact = () => { emit('clearSelectedContact'); state.attachedFiles = []; + removeSignatureFromMessage(); }; const onClickInsertEmoji = emoji => { @@ -354,6 +359,7 @@ const shouldShowMessageEditor = computed(() => { :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :has-errors="validationStates.isMessageInvalid" :has-attachments="state.attachedFiles.length > 0" + :channel-type="inboxChannelType" /> { " enable-variables :show-character-count="false" + :signature="messageSignature" + allow-signature + :send-with-signature="sendWithSignature" + :channel-type="channelType" />