From 441cc065ae414781ddf14be9a45134a33c5643e7 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 29 Jul 2025 08:14:50 +0400 Subject: [PATCH 001/212] feat: add config for OpenAI endpoint (#12051) Co-authored-by: Muhsin Keloth --- config/installation_config.yml | 4 ++ .../super_admin/app_configs_controller.rb | 2 +- .../app/services/llm/base_open_ai_service.rb | 5 +++ .../captain/copilot/chat_service_spec.rb | 43 +++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/config/installation_config.yml b/config/installation_config.yml index fcf4ade2f..53251fc3c 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -170,6 +170,10 @@ display_title: 'OpenAI Model' description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4o-mini' locked: false +- name: CAPTAIN_OPEN_AI_ENDPOINT + display_title: 'OpenAI API Endpoint (optional)' + description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/' + locked: false - name: CAPTAIN_FIRECRAWL_API_KEY display_title: 'FireCrawl API Key (optional)' description: 'The FireCrawl API key for the Captain AI service' diff --git a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb index 41c8411b1..5466c86a4 100644 --- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb +++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb @@ -10,7 +10,7 @@ module Enterprise::SuperAdmin::AppConfigsController when 'internal' @allowed_configs = internal_config_options when 'captain' - @allowed_configs = %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_FIRECRAWL_API_KEY] + @allowed_configs = %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT CAPTAIN_FIRECRAWL_API_KEY] else super end diff --git a/enterprise/app/services/llm/base_open_ai_service.rb b/enterprise/app/services/llm/base_open_ai_service.rb index 1f229f182..04909cbf4 100644 --- a/enterprise/app/services/llm/base_open_ai_service.rb +++ b/enterprise/app/services/llm/base_open_ai_service.rb @@ -4,6 +4,7 @@ class Llm::BaseOpenAiService def initialize @client = OpenAI::Client.new( access_token: InstallationConfig.find_by!(name: 'CAPTAIN_OPEN_AI_API_KEY').value, + uri_base: uri_base, log_errors: Rails.env.development? ) setup_model @@ -13,6 +14,10 @@ class Llm::BaseOpenAiService private + def uri_base + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/' + end + def setup_model config_value = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value @model = (config_value.presence || DEFAULT_MODEL) diff --git a/spec/enterprise/services/captain/copilot/chat_service_spec.rb b/spec/enterprise/services/captain/copilot/chat_service_spec.rb index a4274ec4e..a02063b5e 100644 --- a/spec/enterprise/services/captain/copilot/chat_service_spec.rb +++ b/spec/enterprise/services/captain/copilot/chat_service_spec.rb @@ -22,6 +22,7 @@ RSpec.describe Captain::Copilot::ChatService do before do create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://api.openai.com/') allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client) allow(mock_openai_client).to receive(:chat).and_return({ choices: [{ message: { content: '{ "content": "Hey" }' } }] @@ -47,6 +48,48 @@ RSpec.describe Captain::Copilot::ChatService do expect(messages.second[:role]).to eq('system') expect(messages.second[:content]).to include(account.id.to_s) end + + it 'initializes OpenAI client with configured endpoint' do + expect(OpenAI::Client).to receive(:new).with( + access_token: 'test-key', + uri_base: 'https://api.openai.com/', + log_errors: Rails.env.development? + ) + + described_class.new(assistant, config) + end + + context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do + before do + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy + end + + it 'uses default OpenAI endpoint' do + expect(OpenAI::Client).to receive(:new).with( + access_token: 'test-key', + uri_base: 'https://api.openai.com/', + log_errors: Rails.env.development? + ) + + described_class.new(assistant, config) + end + end + + context 'when custom endpoint is configured' do + before do + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT').update!(value: 'https://custom.azure.com/') + end + + it 'uses custom endpoint for OpenAI client' do + expect(OpenAI::Client).to receive(:new).with( + access_token: 'test-key', + uri_base: 'https://custom.azure.com/', + log_errors: Rails.env.development? + ) + + described_class.new(assistant, config) + end + end end describe '#generate_response' do From 6475a6a59329a45d87c4216031fd14aa0ebfbab7 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 29 Jul 2025 14:24:14 +0400 Subject: [PATCH 002/212] feat: add references header to reply emails (#11719) Co-authored-by: Muhsin Keloth Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/mailers/conversation_reply_mailer.rb | 6 + .../conversation_reply_mailer_helper.rb | 25 ++- app/mailers/references_header_builder.rb | 101 +++++++++++ app/presenters/mail_presenter.rb | 7 + spec/fixtures/files/mail_with_references.eml | 17 ++ spec/mailboxes/reply_mailbox_spec.rb | 2 +- spec/mailboxes/support_mailbox_spec.rb | 25 ++- .../mailers/conversation_reply_mailer_spec.rb | 93 ++++++++++ .../mailers/references_header_builder_spec.rb | 164 ++++++++++++++++++ spec/presenters/mail_presenter_spec.rb | 26 +++ 10 files changed, 449 insertions(+), 17 deletions(-) create mode 100644 app/mailers/references_header_builder.rb create mode 100644 spec/fixtures/files/mail_with_references.eml create mode 100644 spec/mailers/references_header_builder_spec.rb diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index ba46a5fed..360b227cb 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -4,6 +4,7 @@ class ConversationReplyMailer < ApplicationMailer attr_reader :large_attachments include ConversationReplyMailerHelper + include ReferencesHeaderBuilder default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot ') layout :choose_layout @@ -160,6 +161,7 @@ class ConversationReplyMailer < ApplicationMailer end def conversation_reply_email_id + # Find the last incoming message's message_id to reply to content_attributes = @conversation.messages.incoming.last&.content_attributes if content_attributes && content_attributes['email'] && content_attributes['email']['message_id'] @@ -169,6 +171,10 @@ class ConversationReplyMailer < ApplicationMailer nil end + def references_header + build_references_header(@conversation, in_reply_to_email) + end + def cc_bcc_emails content_attributes = @conversation.messages.outgoing.last&.content_attributes diff --git a/app/mailers/conversation_reply_mailer_helper.rb b/app/mailers/conversation_reply_mailer_helper.rb index d34369832..55f7fe12b 100644 --- a/app/mailers/conversation_reply_mailer_helper.rb +++ b/app/mailers/conversation_reply_mailer_helper.rb @@ -6,15 +6,15 @@ module ConversationReplyMailerHelper reply_to: email_reply_to, subject: mail_subject, message_id: custom_message_id, - in_reply_to: in_reply_to_email + in_reply_to: in_reply_to_email, + references: references_header } if cc_bcc_enabled @options[:cc] = cc_bcc_emails[0] @options[:bcc] = cc_bcc_emails[1] end - ms_smtp_settings - google_smtp_settings + oauth_smtp_settings set_delivery_method # Email type detection logic: @@ -57,22 +57,17 @@ module ConversationReplyMailerHelper private - def google_smtp_settings - return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.google? - - smtp_settings = base_smtp_settings('smtp.gmail.com') + def oauth_smtp_settings + return unless @inbox.email? && @channel.imap_enabled + return unless oauth_provider_domain @options[:delivery_method] = :smtp - @options[:delivery_method_options] = smtp_settings + @options[:delivery_method_options] = base_smtp_settings(oauth_provider_domain) end - def ms_smtp_settings - return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.microsoft? - - smtp_settings = base_smtp_settings('smtp.office365.com') - - @options[:delivery_method] = :smtp - @options[:delivery_method_options] = smtp_settings + def oauth_provider_domain + return 'smtp.gmail.com' if @inbox.channel.google? + return 'smtp.office365.com' if @inbox.channel.microsoft? end def base_smtp_settings(domain) diff --git a/app/mailers/references_header_builder.rb b/app/mailers/references_header_builder.rb new file mode 100644 index 000000000..a48d7c9c1 --- /dev/null +++ b/app/mailers/references_header_builder.rb @@ -0,0 +1,101 @@ +# Builds RFC 5322 compliant References headers for email threading +# +# This module provides functionality to construct proper References headers +# that maintain email conversation threading according to RFC 5322 standards. +module ReferencesHeaderBuilder + # Builds a complete References header for an email reply + # + # According to RFC 5322, the References header should contain: + # - References from the message being replied to (if available) + # - The In-Reply-To message ID as the final element + # - Proper line folding if the header exceeds 998 characters + # + # If the message being replied to has no stored References, we use a minimal + # approach with only the In-Reply-To message ID rather than rebuilding. + # + # @param conversation [Conversation] The conversation containing the message thread + # @param in_reply_to_message_id [String] The message ID being replied to + # @return [String] A properly formatted and folded References header value + def build_references_header(conversation, in_reply_to_message_id) + references = get_references_from_replied_message(conversation, in_reply_to_message_id) + references << in_reply_to_message_id + + references = references.compact.uniq + fold_references_header(references) + rescue StandardError => e + Rails.logger.error("Error building references header for ##{conversation.id}: #{e.message}") + ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception + '' + end + + private + + # Gets References header from the message being replied to + # + # Finds the message by its source_id matching the in_reply_to_message_id + # and extracts its stored References header. If no References are found, + # we return an empty array (minimal approach - no rebuilding). + # + # @param conversation [Conversation] The conversation containing the message thread + # @param in_reply_to_message_id [String] The message ID being replied to + # @return [Array] Array of properly formatted message IDs with angle brackets + def get_references_from_replied_message(conversation, in_reply_to_message_id) + return [] if in_reply_to_message_id.blank? + + replied_to_message = find_replied_to_message(conversation, in_reply_to_message_id) + return [] unless replied_to_message + + extract_references_from_message(replied_to_message) + end + + # Finds the message being replied to based on its source_id + # + # @param conversation [Conversation] The conversation containing the message thread + # @param in_reply_to_message_id [String] The message ID to search for + # @return [Message, nil] The message being replied to + def find_replied_to_message(conversation, in_reply_to_message_id) + return nil if in_reply_to_message_id.blank? + + # Remove angle brackets if present for comparison + normalized_id = in_reply_to_message_id.gsub(/[<>]/, '') + + # Use database query to find the message efficiently + # Search for exact match or with angle brackets + conversation.messages + .where.not(source_id: nil) + .where('source_id = ? OR source_id = ? OR source_id = ?', + normalized_id, + "<#{normalized_id}>", + in_reply_to_message_id) + .first + end + + # Extracts References header from a message's content_attributes + # + # @param message [Message] The message to extract References from + # @return [Array] Array of properly formatted message IDs with angle brackets + def extract_references_from_message(message) + return [] unless message.content_attributes&.dig('email', 'references') + + references = message.content_attributes['email']['references'] + Array.wrap(references).map do |ref| + ref.start_with?('<') ? ref : "<#{ref}>" + end + end + + # Folds References header to comply with RFC 5322 line folding requirements + # + # RFC 5322 requires that continuation lines in folded headers start with + # whitespace (space or tab). This method joins message IDs with CRLF + space, + # ensuring the first line has no leading space and all continuation lines + # start with a space as required by the RFC. + # + # @param references_array [Array] Array of message IDs to be folded + # @return [String] A properly folded header value with CRLF line endings + def fold_references_header(references_array) + return '' if references_array.empty? + return references_array.first if references_array.size == 1 + + references_array.join("\r\n ") + end +end diff --git a/app/presenters/mail_presenter.rb b/app/presenters/mail_presenter.rb index 1c951cbe3..890e97a78 100644 --- a/app/presenters/mail_presenter.rb +++ b/app/presenters/mail_presenter.rb @@ -100,6 +100,7 @@ class MailPresenter < SimpleDelegator message_id: message_id, multipart: multipart?, number_of_attachments: number_of_attachments, + references: references, subject: subject, text_content: text_content, to: to @@ -115,6 +116,12 @@ class MailPresenter < SimpleDelegator @mail.in_reply_to.is_a?(Array) ? @mail.in_reply_to.first : @mail.in_reply_to end + def references + return [] if @mail.references.blank? + + Array.wrap(@mail.references) + end + def from # changing to downcase to avoid case mismatch while finding contact (@mail.reply_to.presence || @mail.from).map(&:downcase) diff --git a/spec/fixtures/files/mail_with_references.eml b/spec/fixtures/files/mail_with_references.eml new file mode 100644 index 000000000..4fca32726 --- /dev/null +++ b/spec/fixtures/files/mail_with_references.eml @@ -0,0 +1,17 @@ +From: Sony Mathew +To: care@example.com +Mime-Version: 1.0 (Apple Message framework v1244.3) +Content-Type: multipart/alternative; boundary="Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74" +Subject: Discussion: Let's debate these attachments +Date: Tue, 20 Apr 2020 04:20:20 -0400 +In-Reply-To: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> +References: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> +Message-Id: <0CB459E0-0336-41DA-BC88-E6E28C697DDBF@chatwoot.com> +X-Mailer: Apple Mail (2.1244.3) + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; + charset=utf-8 + +Email with references header \ No newline at end of file diff --git a/spec/mailboxes/reply_mailbox_spec.rb b/spec/mailboxes/reply_mailbox_spec.rb index a9a802bb9..2320e3e07 100644 --- a/spec/mailboxes/reply_mailbox_spec.rb +++ b/spec/mailboxes/reply_mailbox_spec.rb @@ -12,7 +12,7 @@ RSpec.describe ReplyMailbox do let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) } let(:described_subject) { described_class.receive reply_mail } let(:serialized_attributes) do - %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments subject text_content to] + %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to] end context 'with reply uuid present' do diff --git a/spec/mailboxes/support_mailbox_spec.rb b/spec/mailboxes/support_mailbox_spec.rb index f9e9aa2ff..f75e3ef80 100644 --- a/spec/mailboxes/support_mailbox_spec.rb +++ b/spec/mailboxes/support_mailbox_spec.rb @@ -55,7 +55,7 @@ RSpec.describe SupportMailbox do let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') } let(:described_subject) { described_class.receive support_mail } let(:serialized_attributes) do - %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments subject + %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to] end let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last } @@ -111,6 +111,29 @@ RSpec.describe SupportMailbox do end end + describe 'email with references header' do + let(:mail_with_references) { create_inbound_email_from_fixture('mail_with_references.eml') } + let(:described_subject) { described_class.receive mail_with_references } + + before do + # reuse the existing channel_email that's already set to 'care@example.com' + described_subject + end + + it 'includes references in the message content_attributes' do + message = conversation.messages.last + email_attributes = message.content_attributes['email'] + + expect(email_attributes['references']).to be_present + expect(email_attributes['references']).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id']) + end + + it 'includes references in serialized email attributes' do + message = conversation.messages.last + expect(message.content_attributes['email'].keys).to include('references') + end + end + describe 'Sender without name' do let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') } let(:described_subject) { described_class.receive support_mail_without_sender_name } diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index 2a0d6c8b0..ecd97333e 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -137,6 +137,99 @@ RSpec.describe ConversationReplyMailer do end end + context 'with references header' do + let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload } + let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') } + let(:mail) { described_class.email_reply(message).deliver_now } + + context 'when starting a new conversation' do + let(:first_outgoing_message) do + create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'First outgoing message') + end + let(:mail) { described_class.email_reply(first_outgoing_message).deliver_now } + + it 'has only the conversation reference' do + # When starting a conversation, references will have the default conversation ID + # Extract domain from the actual references header to handle dynamic domain selection + actual_domain = mail.references.split('@').last + expected_reference = "account/#{account.id}/conversation/#{conversation.uuid}@#{actual_domain}" + expect(mail.references).to eq(expected_reference) + end + end + + context 'when replying to a message with no references' do + let(:incoming_message) do + create(:message, + conversation: conversation, + account: account, + message_type: 'incoming', + source_id: '', + content: 'Incoming message', + content_attributes: { + 'email' => { + 'message_id' => 'incoming-123@example.com' + } + }) + end + let(:reply_message) do + create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Reply to incoming') + end + let(:mail) { described_class.email_reply(reply_message).deliver_now } + + before do + incoming_message + end + + it 'includes only the in_reply_to id in references' do + # References should only have the incoming message ID when no prior references exist + expect(mail.references).to eq('incoming-123@example.com') + end + end + + context 'when replying to a message that has references' do + let(:incoming_message_with_refs) do + create(:message, + conversation: conversation, + account: account, + message_type: 'incoming', + source_id: '', + content: 'Incoming with references', + content_attributes: { + 'email' => { + 'message_id' => 'incoming-456@example.com', + 'references' => ['', ''] + } + }) + end + let(:reply_message) do + create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Reply to message with refs') + end + let(:mail) { described_class.email_reply(reply_message).deliver_now } + + before do + incoming_message_with_refs + end + + it 'includes existing references plus the in_reply_to id' do + # Rails returns references as an array when multiple values are present + expected_references = ['ref-1@example.com', 'ref-2@example.com', 'incoming-456@example.com'] + expect(mail.references).to eq(expected_references) + end + end + end + context 'with email reply' do let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload } let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') } diff --git a/spec/mailers/references_header_builder_spec.rb b/spec/mailers/references_header_builder_spec.rb new file mode 100644 index 000000000..b10d63dba --- /dev/null +++ b/spec/mailers/references_header_builder_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ReferencesHeaderBuilder do + include described_class + + let(:account) { create(:account) } + let(:email_channel) { create(:channel_email, account: account) } + let(:inbox) { create(:inbox, channel: email_channel, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + + describe '#build_references_header' do + let(:in_reply_to_message_id) { '' } + + context 'when no message is found with the in_reply_to_message_id' do + it 'returns only the in_reply_to message ID' do + result = build_references_header(conversation, in_reply_to_message_id) + expect(result).to eq('') + end + end + + context 'when a message is found with matching source_id' do + context 'with stored References' do + let(:original_message) do + create(:message, conversation: conversation, account: account, + source_id: '', + content_attributes: { + 'email' => { + 'references' => ['', ''] + } + }) + end + + before do + original_message + end + + it 'includes stored References plus in_reply_to' do + result = build_references_header(conversation, in_reply_to_message_id) + expect(result).to eq("\r\n \r\n ") + end + + it 'removes duplicates while preserving order' do + # If in_reply_to is already in the References, it should appear only once at the end + original_message.content_attributes['email']['references'] = ['', ''] + original_message.save! + + result = build_references_header(conversation, in_reply_to_message_id) + message_ids = result.split("\r\n ").map(&:strip) + expect(message_ids).to eq(['', '']) + end + end + + context 'without stored References' do + let(:original_message) do + create(:message, conversation: conversation, account: account, + source_id: 'reply-to-123@example.com', # without angle brackets + content_attributes: { 'email' => {} }) + end + + before do + original_message + end + + it 'returns only the in_reply_to message ID (no rebuild)' do + result = build_references_header(conversation, in_reply_to_message_id) + expect(result).to eq('') + end + end + end + + context 'with folding multiple References' do + let(:original_message) do + create(:message, conversation: conversation, account: account, + source_id: '', + content_attributes: { + 'email' => { + 'references' => ['', '', ''] + } + }) + end + + before do + original_message + end + + it 'folds the header with CRLF between message IDs' do + result = build_references_header(conversation, in_reply_to_message_id) + + expect(result).to include("\r\n") + lines = result.split("\r\n") + + # First line has no leading space, continuation lines do + expect(lines.first).not_to start_with(' ') + expect(lines[1..]).to all(start_with(' ')) + end + end + + context 'with source_id in different formats' do + it 'finds message with source_id without angle brackets' do + create(:message, conversation: conversation, account: account, + source_id: 'test-123@example.com', + content_attributes: { + 'email' => { + 'references' => [''] + } + }) + + result = build_references_header(conversation, '') + expect(result).to eq("\r\n ") + end + + it 'finds message with source_id with angle brackets' do + create(:message, conversation: conversation, account: account, + source_id: '', + content_attributes: { + 'email' => { + 'references' => [''] + } + }) + + result = build_references_header(conversation, 'test-456@example.com') + expect(result).to eq("\r\n test-456@example.com") + end + end + end + + describe '#fold_references_header' do + it 'returns single message ID without folding' do + single_array = [''] + result = fold_references_header(single_array) + + expect(result).to eq('') + expect(result).not_to include("\r\n") + end + + it 'folds multiple message IDs with CRLF + space' do + multiple_array = ['', '', ''] + result = fold_references_header(multiple_array) + + expect(result).to eq("\r\n \r\n ") + end + + it 'ensures RFC 5322 compliance with continuation line spacing' do + multiple_array = ['', ''] + result = fold_references_header(multiple_array) + lines = result.split("\r\n") + + # First line has no leading space (not a continuation line) + expect(lines.first).to eq('') + expect(lines.first).not_to start_with(' ') + + # Second line starts with space (continuation line) + expect(lines[1]).to eq(' ') + expect(lines[1]).to start_with(' ') + end + + it 'handles empty array' do + result = fold_references_header([]) + expect(result).to eq('') + end + end +end diff --git a/spec/presenters/mail_presenter_spec.rb b/spec/presenters/mail_presenter_spec.rb index d5b313ce9..af6768e41 100644 --- a/spec/presenters/mail_presenter_spec.rb +++ b/spec/presenters/mail_presenter_spec.rb @@ -46,6 +46,7 @@ RSpec.describe MailPresenter do :message_id, :multipart, :number_of_attachments, + :references, :subject, :text_content, :to @@ -100,6 +101,31 @@ RSpec.describe MailPresenter do end end + describe '#references' do + let(:references_mail) { create_inbound_email_from_fixture('references.eml').mail } + let(:mail_presenter_with_references) { described_class.new(references_mail) } + + context 'when mail has references' do + it 'returns an array of reference IDs' do + expect(mail_presenter_with_references.references).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id']) + end + end + + context 'when mail has no references' do + it 'returns an empty array' do + mail_presenter = described_class.new(mail_without_in_reply_to) + expect(mail_presenter.references).to eq([]) + end + end + + context 'when references are included in serialized_data' do + it 'includes references in the serialized data' do + data = mail_presenter_with_references.serialized_data + expect(data[:references]).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id']) + end + end + end + describe 'auto_reply?' do let(:auto_reply_mail) { create_inbound_email_from_fixture('auto_reply.eml').mail } let(:auto_reply_with_auto_submitted_mail) { create_inbound_email_from_fixture('auto_reply_with_auto_submitted.eml').mail } From 75c57ad039341e6681cba726de16c35ccbc49e52 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 30 Jul 2025 08:58:27 +0400 Subject: [PATCH 003/212] feat: use captain endpoint config in legacy OpenAI base service (#12060) This PR migrates the legacy OpenAI integration (where users provide their own API keys) from using hardcoded `https://api.openai.com` endpoints to use the configurable `CAPTAIN_OPEN_AI_ENDPOINT` from the captain configuration. This ensures consistency across all OpenAI integrations in the platform. ## Changes - Updated `lib/integrations/openai_base_service.rb` to use captain endpoint config - Updated `enterprise/app/models/enterprise/concerns/article.rb` to use captain endpoint config - Removed unused `enterprise/lib/chat_gpt.rb` class - Added tests for endpoint configuration behavior --- .../app/models/enterprise/concerns/article.rb | 10 ++- enterprise/lib/chat_gpt.rb | 62 ------------------- lib/integrations/openai_base_service.rb | 9 ++- .../openai/processor_service_spec.rb | 47 ++++++++++++++ 4 files changed, 63 insertions(+), 65 deletions(-) delete mode 100644 enterprise/lib/chat_gpt.rb diff --git a/enterprise/app/models/enterprise/concerns/article.rb b/enterprise/app/models/enterprise/concerns/article.rb index b7de767ad..d3a94d7b7 100644 --- a/enterprise/app/models/enterprise/concerns/article.rb +++ b/enterprise/app/models/enterprise/concerns/article.rb @@ -68,8 +68,16 @@ module Enterprise::Concerns::Article headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', nil)}" } body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json Rails.logger.info "Requesting Chat GPT with body: #{body}" - response = HTTParty.post('https://api.openai.com/v1/chat/completions', headers: headers, body: body) + response = HTTParty.post(openai_api_url, headers: headers, body: body) Rails.logger.info "Chat GPT response: #{response.body}" JSON.parse(response.parsed_response['choices'][0]['message']['content'])['search_terms'] end + + private + + def openai_api_url + endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/' + endpoint = endpoint.chomp('/') + "#{endpoint}/v1/chat/completions" + end end diff --git a/enterprise/lib/chat_gpt.rb b/enterprise/lib/chat_gpt.rb deleted file mode 100644 index 44afbd641..000000000 --- a/enterprise/lib/chat_gpt.rb +++ /dev/null @@ -1,62 +0,0 @@ -class ChatGpt - def self.base_uri - 'https://api.openai.com' - end - - def initialize(context_sections = '') - @model = 'gpt-4o' - @messages = [system_message(context_sections)] - end - - def generate_response(input, previous_messages = [], role = 'user') - @messages += previous_messages - @messages << { 'role': role, 'content': input } if input.present? - - response = request_gpt - JSON.parse(response['choices'][0]['message']['content'].strip) - end - - private - - def system_message(context_sections) - { - 'role': 'system', - 'content': system_content(context_sections) - } - end - - def system_content(context_sections) - <<~SYSTEM_PROMPT_MESSAGE - You are a very enthusiastic customer support representative who loves to help people. - Your answers will always be formatted in valid JSON hash, as shown below. Never respond in non JSON format. - - ``` - { - response: '' , - context_ids: [ids], - } - ``` - - response: will be the next response to the conversation - - context_ids: will be an array of unique context IDs that were used to generate the answer. choose top 3. - - The answers will be generated using the information provided at the end of the prompt under the context sections. You will not respond outside the context of the information provided in context sections. - - If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response - - ---------------------------------- - Context sections: - #{context_sections} - SYSTEM_PROMPT_MESSAGE - end - - def request_gpt - headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" } - body = { model: @model, messages: @messages, response_format: { type: 'json_object' } }.to_json - Rails.logger.info "Requesting Chat GPT with body: #{body}" - response = HTTParty.post("#{self.class.base_uri}/v1/chat/completions", headers: headers, body: body) - Rails.logger.info "Chat GPT response: #{response.body}" - JSON.parse(response.body) - end -end diff --git a/lib/integrations/openai_base_service.rb b/lib/integrations/openai_base_service.rb index 908e496a7..f06baf5b5 100644 --- a/lib/integrations/openai_base_service.rb +++ b/lib/integrations/openai_base_service.rb @@ -4,7 +4,6 @@ class Integrations::OpenaiBaseService # sticking with 120000 to be safe # 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe) TOKEN_LIMIT = 400_000 - API_URL = 'https://api.openai.com/v1/chat/completions'.freeze GPT_MODEL = ENV.fetch('OPENAI_GPT_MODEL', 'gpt-4o-mini').freeze ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze @@ -81,6 +80,12 @@ class Integrations::OpenaiBaseService self.class::CACHEABLE_EVENTS.include?(event_name) end + def api_url + endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/' + endpoint = endpoint.chomp('/') + "#{endpoint}/v1/chat/completions" + end + def make_api_call(body) headers = { 'Content-Type' => 'application/json', @@ -88,7 +93,7 @@ class Integrations::OpenaiBaseService } Rails.logger.info("OpenAI API request: #{body}") - response = HTTParty.post(API_URL, headers: headers, body: body) + response = HTTParty.post(api_url, headers: headers, body: body) Rails.logger.info("OpenAI API response: #{response.body}") return { error: response.parsed_response, error_code: response.code } unless response.success? diff --git a/spec/lib/integrations/openai/processor_service_spec.rb b/spec/lib/integrations/openai/processor_service_spec.rb index 8bbf5d5fb..a22c8e815 100644 --- a/spec/lib/integrations/openai/processor_service_spec.rb +++ b/spec/lib/integrations/openai/processor_service_spec.rb @@ -253,5 +253,52 @@ RSpec.describe Integrations::Openai::ProcessorService do expect(result).to eq({ :message => 'This is a reply from openai.' }) end end + + context 'when testing endpoint configuration' do + let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } } + + context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do + it 'uses default OpenAI endpoint' do + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy + + stub_request(:post, 'https://api.openai.com/v1/chat/completions') + .with(body: anything, headers: expected_headers) + .to_return(status: 200, body: openai_response, headers: {}) + + result = subject.perform + expect(result).to eq({ :message => 'This is a reply from openai.' }) + end + end + + context 'when CAPTAIN_OPEN_AI_ENDPOINT is configured' do + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/') + end + + it 'uses custom endpoint' do + stub_request(:post, 'https://custom.azure.com/v1/chat/completions') + .with(body: anything, headers: expected_headers) + .to_return(status: 200, body: openai_response, headers: {}) + + result = subject.perform + expect(result).to eq({ :message => 'This is a reply from openai.' }) + end + end + + context 'when CAPTAIN_OPEN_AI_ENDPOINT has trailing slash' do + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/') + end + + it 'properly handles trailing slash' do + stub_request(:post, 'https://custom.azure.com/v1/chat/completions') + .with(body: anything, headers: expected_headers) + .to_return(status: 200, body: openai_response, headers: {}) + + result = subject.perform + expect(result).to eq({ :message => 'This is a reply from openai.' }) + end + end + end end end From 62b36d4aec2318e2f4c7b87605bfc9b94dac568e Mon Sep 17 00:00:00 2001 From: Chatwoot Bot <92152627+chatwoot-bot@users.noreply.github.com> Date: Tue, 29 Jul 2025 23:06:32 -0700 Subject: [PATCH 004/212] chore: Update translations (#12056) --- app/javascript/dashboard/i18n/locale/pl/agentBots.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/javascript/dashboard/i18n/locale/pl/agentBots.json b/app/javascript/dashboard/i18n/locale/pl/agentBots.json index d861bdead..6060d7e43 100644 --- a/app/javascript/dashboard/i18n/locale/pl/agentBots.json +++ b/app/javascript/dashboard/i18n/locale/pl/agentBots.json @@ -2,7 +2,7 @@ "AGENT_BOTS": { "HEADER": "Boty", "LOADING_EDITOR": "Ładowanie edytora...", - "DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.", + "DESCRIPTION": "Boty agentów są jak najbardziej fantastyczni członkowie Twojego zespołu. Mogą zajmować się drobnymi sprawami, dzięki czemu Ty możesz skupić się na tym, co naprawdę ważne. Wypróbuj je! Możesz zarządzać swoimi botami z tej strony lub tworzyć nowe za pomocą przycisku 'Dodaj bota'.", "LEARN_MORE": "Learn about agent bots", "GLOBAL_BOT": "System bot", "GLOBAL_BOT_BADGE": "System", @@ -30,10 +30,10 @@ } }, "LIST": { - "404": "No bots found. You can create a bot by clicking the 'Add Bot' button.", + "404": "Nie znaleziono botów. Możesz utworzyć bota klikając przycisk 'Dodaj bota'.", "LOADING": "Pobieranie botów...", "TABLE_HEADER": { - "DETAILS": "Bot Details", + "DETAILS": "Szczegóły bota", "URL": "Adres URL webhooka" } }, From 1230d1f2512e5f2135564a91aff4974d43c26094 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 30 Jul 2025 11:07:18 +0400 Subject: [PATCH 005/212] chore: Added support for inbox variables (#11952) --- .../components/widgets/conversation/ReplyBox.vue | 1 + app/javascript/shared/constants/messages.js | 8 ++++++++ package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 8bf81d0c3..e5243ac04 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -372,6 +372,7 @@ export default { const variables = getMessageVariables({ conversation: this.currentChat, contact: this.currentContact, + inbox: this.inbox, }); return variables; }, diff --git a/app/javascript/shared/constants/messages.js b/app/javascript/shared/constants/messages.js index f5fa834fc..7b7b4f331 100644 --- a/app/javascript/shared/constants/messages.js +++ b/app/javascript/shared/constants/messages.js @@ -157,6 +157,14 @@ export const MESSAGE_VARIABLES = [ label: 'Agent email', key: 'agent.email', }, + { + key: 'inbox.name', + label: 'Inbox name', + }, + { + label: 'Inbox id', + key: 'inbox.id', + }, ]; export const ATTACHMENT_ICONS = { diff --git a/package.json b/package.json index e0fd2cf7b..668ea8b57 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.1.6-next", - "@chatwoot/utils": "^0.0.47", + "@chatwoot/utils": "^0.0.48", "@formkit/core": "^1.6.7", "@formkit/vue": "^1.6.7", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a88cfc084..16e830a87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,8 +23,8 @@ importers: specifier: 1.1.6-next version: 1.1.6-next '@chatwoot/utils': - specifier: ^0.0.47 - version: 0.0.47 + specifier: ^0.0.48 + version: 0.0.48 '@formkit/core': specifier: ^1.6.7 version: 1.6.7 @@ -406,8 +406,8 @@ packages: '@chatwoot/prosemirror-schema@1.1.6-next': resolution: {integrity: sha512-9lf7FrcED/B5oyGrMmIkbegkhlC/P0NrtXoX8k94YWRosZcx0hGVGhpTud+0Mhm7saAfGerKIwTRVDmmnxPuCA==} - '@chatwoot/utils@0.0.47': - resolution: {integrity: sha512-0z/MY+rBjDnf6zuWbMdzexH+zFDXU/g5fPr/kcUxnqtvPsZIQpL8PvwSPBW0+wS6R7LChndNkdviV1e9H8Yp+Q==} + '@chatwoot/utils@0.0.48': + resolution: {integrity: sha512-67M2lvpBp0Ciczv1uRzabOXSCGiEeJE3wYVoPAxkqI35CJSkotu4tSX2TFOwagUQoRyU6F8YV3xXGfCpDN9WAA==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -5255,7 +5255,7 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.47': + '@chatwoot/utils@0.0.48': dependencies: date-fns: 2.30.0 From df4de508e70deefb8fa88b09bc69add7a0bdb798 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 30 Jul 2025 19:34:27 +0530 Subject: [PATCH 006/212] feat: New Scenarios page (#11975) --- .../dashboard/api/captain/scenarios.js | 36 ++ app/javascript/dashboard/api/captain/tools.js | 16 + .../components-next/Editor/Editor.vue | 2 + .../captain/assistant/AddNewRulesDialog.vue | 6 +- .../assistant/AddNewScenariosDialog.vue | 155 +++++++++ .../captain/assistant/RuleCard.vue | 1 - .../captain/assistant/ScenariosCard.story.vue | 45 +++ .../captain/assistant/ScenariosCard.vue | 218 ++++++++++++ .../captain/assistant/ToolsDropdown.story.vue | 37 ++ .../captain/assistant/ToolsDropdown.vue | 54 +++ .../components/widgets/WootWriter/Editor.vue | 26 +- .../widgets/conversation/TagTools.vue | 56 +++ .../helper/AnalyticsHelper/events.js | 1 + .../dashboard/helper/editorHelper.js | 11 + .../i18n/locale/en/integrations.json | 69 ++++ .../captain/assistants/guardrails/Index.vue | 5 + .../captain/assistants/guidelines/Index.vue | 7 + .../captain/assistants/scenarios/Index.vue | 320 ++++++++++++++++++ .../captain/assistants/settings/Settings.vue | 2 +- .../dashboard/captain/captain.routes.js | 16 + .../dashboard/store/captain/scenarios.js | 38 +++ .../dashboard/store/captain/tools.js | 24 ++ app/javascript/dashboard/store/index.js | 4 + .../captain/scenarios/index.json.jbuilder | 7 +- package.json | 2 +- pnpm-lock.yaml | 10 +- .../captain/scenarios_controller_spec.rb | 8 +- 27 files changed, 1161 insertions(+), 15 deletions(-) create mode 100644 app/javascript/dashboard/api/captain/scenarios.js create mode 100644 app/javascript/dashboard/api/captain/tools.js create mode 100644 app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue create mode 100644 app/javascript/dashboard/components-next/captain/assistant/ScenariosCard.story.vue create mode 100644 app/javascript/dashboard/components-next/captain/assistant/ScenariosCard.vue create mode 100644 app/javascript/dashboard/components-next/captain/assistant/ToolsDropdown.story.vue create mode 100644 app/javascript/dashboard/components-next/captain/assistant/ToolsDropdown.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/TagTools.vue create mode 100644 app/javascript/dashboard/routes/dashboard/captain/assistants/scenarios/Index.vue create mode 100644 app/javascript/dashboard/store/captain/scenarios.js create mode 100644 app/javascript/dashboard/store/captain/tools.js diff --git a/app/javascript/dashboard/api/captain/scenarios.js b/app/javascript/dashboard/api/captain/scenarios.js new file mode 100644 index 000000000..3e61c28a3 --- /dev/null +++ b/app/javascript/dashboard/api/captain/scenarios.js @@ -0,0 +1,36 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainScenarios extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ assistantId, page = 1, searchKey } = {}) { + return axios.get(`${this.url}/${assistantId}/scenarios`, { + params: { page, searchKey }, + }); + } + + show({ assistantId, id }) { + return axios.get(`${this.url}/${assistantId}/scenarios/${id}`); + } + + create({ assistantId, ...data } = {}) { + return axios.post(`${this.url}/${assistantId}/scenarios`, { + scenario: data, + }); + } + + update({ assistantId, id }, data = {}) { + return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, { + scenario: data, + }); + } + + delete({ assistantId, id }) { + return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`); + } +} + +export default new CaptainScenarios(); diff --git a/app/javascript/dashboard/api/captain/tools.js b/app/javascript/dashboard/api/captain/tools.js new file mode 100644 index 000000000..20edaa95e --- /dev/null +++ b/app/javascript/dashboard/api/captain/tools.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainTools extends ApiClient { + constructor() { + super('captain/assistants/tools', { accountScoped: true }); + } + + get(params = {}) { + return axios.get(this.url, { + params, + }); + } +} + +export default new CaptainTools(); diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index 9e5ff6ab5..a2f139bdc 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -20,6 +20,7 @@ const props = defineProps({ enableVariables: { type: Boolean, default: false }, enableCannedResponses: { type: Boolean, default: true }, enabledMenuOptions: { type: Array, default: () => [] }, + enableCaptainTools: { type: Boolean, default: false }, }); const emit = defineEmits(['update:modelValue']); @@ -98,6 +99,7 @@ watch( :enable-variables="enableVariables" :enable-canned-responses="enableCannedResponses" :enabled-menu-options="enabledMenuOptions" + :enable-captain-tools="enableCaptainTools" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue b/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue index ecdb2d654..c1a465c64 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/AddNewRulesDialog.vue @@ -1,5 +1,6 @@