-
-
-
-
-
- {{ $t('GENERAL_SETTINGS.UPGRADE') }}
-
-
-
-
- {{ limitExceededMessage }}
-
-
- {{ t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
-
-
+
+
+
+
+
+
+
+
+ {{ $t('GENERAL_SETTINGS.UPGRADE') }}
+
+
+
+
+ {{ limitExceededMessage }}
+
+
+ {{ t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
+
-
+
-
-
+
+
+
diff --git a/app/javascript/dashboard/store/captain/scenarios.js b/app/javascript/dashboard/store/captain/scenarios.js
new file mode 100644
index 000000000..d66992d85
--- /dev/null
+++ b/app/javascript/dashboard/store/captain/scenarios.js
@@ -0,0 +1,38 @@
+import CaptainScenarios from 'dashboard/api/captain/scenarios';
+import { createStore } from './storeFactory';
+import { throwErrorMessage } from 'dashboard/store/utils/api';
+
+export default createStore({
+ name: 'CaptainScenario',
+ API: CaptainScenarios,
+ actions: mutations => ({
+ update: async ({ commit }, { id, assistantId, ...updateObj }) => {
+ commit(mutations.SET_UI_FLAG, { updatingItem: true });
+ try {
+ const response = await CaptainScenarios.update(
+ { id, assistantId },
+ updateObj
+ );
+ commit(mutations.EDIT, response.data);
+ commit(mutations.SET_UI_FLAG, { updatingItem: false });
+ return response.data;
+ } catch (error) {
+ commit(mutations.SET_UI_FLAG, { updatingItem: false });
+ return throwErrorMessage(error);
+ }
+ },
+
+ delete: async ({ commit }, { id, assistantId }) => {
+ commit(mutations.SET_UI_FLAG, { deletingItem: true });
+ try {
+ await CaptainScenarios.delete({ id, assistantId });
+ commit(mutations.DELETE, id);
+ commit(mutations.SET_UI_FLAG, { deletingItem: false });
+ return id;
+ } catch (error) {
+ commit(mutations.SET_UI_FLAG, { deletingItem: false });
+ return throwErrorMessage(error);
+ }
+ },
+ }),
+});
diff --git a/app/javascript/dashboard/store/captain/tools.js b/app/javascript/dashboard/store/captain/tools.js
new file mode 100644
index 000000000..9a9bcc330
--- /dev/null
+++ b/app/javascript/dashboard/store/captain/tools.js
@@ -0,0 +1,24 @@
+import { createStore } from './storeFactory';
+import CaptainToolsAPI from '../../api/captain/tools';
+import { throwErrorMessage } from 'dashboard/store/utils/api';
+
+const toolsStore = createStore({
+ name: 'captainTool',
+ API: CaptainToolsAPI,
+ actions: mutations => ({
+ getTools: async ({ commit }) => {
+ commit(mutations.SET_UI_FLAG, { fetchingList: true });
+ try {
+ const response = await CaptainToolsAPI.get();
+ commit(mutations.SET, response.data);
+ commit(mutations.SET_UI_FLAG, { fetchingList: false });
+ return response.data;
+ } catch (error) {
+ commit(mutations.SET_UI_FLAG, { fetchingList: false });
+ return throwErrorMessage(error);
+ }
+ },
+ }),
+});
+
+export default toolsStore;
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index 960285ebf..5a020dda6 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -53,6 +53,8 @@ import captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions';
import copilotThreads from './captain/copilotThreads';
import copilotMessages from './captain/copilotMessages';
+import captainScenarios from './captain/scenarios';
+import captainTools from './captain/tools';
const plugins = [];
@@ -111,6 +113,8 @@ export default createStore({
captainBulkActions,
copilotThreads,
copilotMessages,
+ captainScenarios,
+ captainTools,
},
plugins,
});
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js b/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js
index 05a28756c..130ea097e 100644
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/actions.js
@@ -116,4 +116,24 @@ export const actions = {
isSwitching,
});
},
+
+ sendCnameInstructions: async (_, { portalSlug, email }) => {
+ try {
+ await portalAPIs.sendCnameInstructions(portalSlug, email);
+ } catch (error) {
+ throwErrorMessage(error);
+ }
+ },
+
+ sslStatus: async ({ commit }, { portalSlug }) => {
+ try {
+ commit(types.SET_UI_FLAG, { isFetchingSSLStatus: true });
+ const { data } = await portalAPIs.sslStatus(portalSlug);
+ commit(types.SET_SSL_SETTINGS, { portalSlug, sslSettings: data });
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ commit(types.SET_UI_FLAG, { isFetchingSSLStatus: false });
+ }
+ },
};
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js b/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js
index 7dd8b2b22..f40af2502 100644
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/getters.js
@@ -8,6 +8,7 @@ export const getters = {
isFetchingPortals: state => state.uiFlags.isFetching,
isCreatingPortal: state => state.uiFlags.isCreating,
isSwitchingPortal: state => state.uiFlags.isSwitching,
+ isFetchingSSLStatus: state => state.uiFlags.isFetchingSSLStatus,
portalBySlug:
(...getterArguments) =>
portalId => {
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/index.js b/app/javascript/dashboard/store/modules/helpCenterPortals/index.js
index 621e180e5..4feb098c0 100755
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/index.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/index.js
@@ -6,6 +6,7 @@ export const defaultPortalFlags = {
isFetching: false,
isUpdating: false,
isDeleting: false,
+ isFetchingSSLStatus: false,
};
const state = {
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js b/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js
index 7e14bc63e..3f2fce3c8 100644
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/mutations.js
@@ -13,6 +13,7 @@ export const types = {
REMOVE_PORTAL_ID: 'removePortalId',
SET_HELP_PORTAL_UI_FLAG: 'setHelpCenterUIFlag',
SET_PORTAL_SWITCHING_FLAG: 'setPortalSwitchingFlag',
+ SET_SSL_SETTINGS: 'setSSLSettings',
};
export const mutations = {
@@ -110,4 +111,18 @@ export const mutations = {
[types.SET_PORTAL_SWITCHING_FLAG]($state, { isSwitching }) {
$state.uiFlags.isSwitching = isSwitching;
},
+
+ [types.SET_SSL_SETTINGS]($state, { portalSlug, sslSettings }) {
+ const portal = $state.portals.byId[portalSlug];
+ $state.portals.byId = {
+ ...$state.portals.byId,
+ [portalSlug]: {
+ ...portal,
+ ssl_settings: {
+ ...portal.ssl_settings,
+ ...sslSettings,
+ },
+ },
+ };
+ },
};
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js
index 9acbbb0b5..9bd85cb98 100644
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/actions.spec.js
@@ -135,6 +135,36 @@ describe('#actions', () => {
});
});
+ describe('#sslStatus', () => {
+ it('commits SET_SSL_SETTINGS with data from API', async () => {
+ axios.get.mockResolvedValue({
+ data: { status: 'active', verification_errors: [] },
+ });
+ await actions.sslStatus({ commit }, { portalSlug: 'domain' });
+ expect(commit.mock.calls).toEqual([
+ [types.SET_UI_FLAG, { isFetchingSSLStatus: true }],
+ [
+ types.SET_SSL_SETTINGS,
+ {
+ portalSlug: 'domain',
+ sslSettings: { status: 'active', verification_errors: [] },
+ },
+ ],
+ [types.SET_UI_FLAG, { isFetchingSSLStatus: false }],
+ ]);
+ });
+ it('throws error and does not commit when API fails', async () => {
+ axios.get.mockRejectedValue({ message: 'error' });
+ await expect(
+ actions.sslStatus({ commit }, { portalSlug: 'domain' })
+ ).rejects.toThrow(Error);
+ expect(commit.mock.calls).toEqual([
+ [types.SET_UI_FLAG, { isFetchingSSLStatus: true }],
+ [types.SET_UI_FLAG, { isFetchingSSLStatus: false }],
+ ]);
+ });
+ });
+
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
diff --git a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js
index 2e58c1de7..c468ee017 100644
--- a/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterPortals/specs/mutations.spec.js
@@ -89,6 +89,25 @@ describe('#mutations', () => {
isFetching: true,
isUpdating: false,
isDeleting: false,
+ isFetchingSSLStatus: false,
+ });
+ });
+ });
+
+ describe('[types.SET_SSL_SETTINGS]', () => {
+ it('merges new ssl settings into existing portal.ssl_settings', () => {
+ state.portals.byId.domain = {
+ slug: 'domain',
+ ssl_settings: { cf_status: 'pending' },
+ };
+ mutations[types.SET_SSL_SETTINGS](state, {
+ portalSlug: 'domain',
+ sslSettings: { status: 'active', verification_errors: ['error'] },
+ });
+ expect(state.portals.byId.domain.ssl_settings).toEqual({
+ cf_status: 'pending',
+ status: 'active',
+ verification_errors: ['error'],
});
});
});
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/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb
index 56e8c2235..ea2705955 100644
--- a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb
+++ b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb
@@ -1,5 +1,6 @@
class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
queue_as :scheduled_jobs
+ include BillingHelper
def perform
email_inboxes = Inbox.where(channel_type: 'Channel::Email')
@@ -11,6 +12,13 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
private
def should_fetch_emails?(inbox)
- inbox.channel.imap_enabled && !inbox.account.suspended?
+ return false if inbox.account.suspended?
+ return false unless inbox.channel.imap_enabled
+ return false if inbox.channel.reauthorization_required?
+
+ return true unless ChatwootApp.chatwoot_cloud?
+ return false if default_plan?(inbox.account)
+
+ true
end
end
diff --git a/app/mailboxes/incoming_email_validity_helper.rb b/app/mailboxes/incoming_email_validity_helper.rb
index 252ef5257..9483c9768 100644
--- a/app/mailboxes/incoming_email_validity_helper.rb
+++ b/app/mailboxes/incoming_email_validity_helper.rb
@@ -4,16 +4,17 @@ module IncomingEmailValidityHelper
def incoming_email_from_valid_email?
return false unless valid_external_email_for_active_account?
+ # Return if email doesn't have a valid sender
+ # This can happen in cases like bounce emails for invalid contact email address
+ return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
+
+ # Process bounced emails, as regular emails
+ return true if @processed_mail.bounced?
+
# we skip processing auto reply emails like delivery status notifications
# out of office replies, etc.
return false if auto_reply_email?
- # return if email doesn't have a valid sender
- # This can happen in cases like bounce emails for invalid contact email address
- # TODO: Handle the bounce separately and mark the contact as invalid in case of reply bounces
- # The returned value could be "\"\"" for some email clients
- return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
-
true
end
diff --git a/app/mailers/portal_instructions_mailer.rb b/app/mailers/portal_instructions_mailer.rb
new file mode 100644
index 000000000..284e3e9de
--- /dev/null
+++ b/app/mailers/portal_instructions_mailer.rb
@@ -0,0 +1,41 @@
+class PortalInstructionsMailer < ApplicationMailer
+ def send_cname_instructions(portal:, recipient_email:)
+ return unless smtp_config_set_or_development?
+ return if target_domain.blank?
+
+ @portal = portal
+ @cname_record = generate_cname_record
+
+ send_mail_with_liquid(
+ to: recipient_email,
+ subject: I18n.t('portals.send_instructions.subject', custom_domain: @portal.custom_domain)
+ )
+ end
+
+ private
+
+ def liquid_locals
+ super.merge({ cname_record: @cname_record })
+ end
+
+ def generate_cname_record
+ "#{@portal.custom_domain} CNAME #{target_domain}"
+ end
+
+ def target_domain
+ helpcenter_url = ENV.fetch('HELPCENTER_URL', '')
+ frontend_url = ENV.fetch('FRONTEND_URL', '')
+
+ return extract_hostname(helpcenter_url) if helpcenter_url.present?
+ return extract_hostname(frontend_url) if frontend_url.present?
+
+ ''
+ end
+
+ def extract_hostname(url)
+ uri = URI.parse(url)
+ uri.host
+ rescue URI::InvalidURIError
+ url.gsub(%r{https?://}, '').split('/').first
+ end
+end
diff --git a/app/policies/portal_policy.rb b/app/policies/portal_policy.rb
index 1e09c41f6..0eace233c 100644
--- a/app/policies/portal_policy.rb
+++ b/app/policies/portal_policy.rb
@@ -26,6 +26,14 @@ class PortalPolicy < ApplicationPolicy
def logo?
@account_user.administrator?
end
+
+ def send_instructions?
+ @account_user.administrator?
+ end
+
+ def ssl_status?
+ @account.users.include?(@user)
+ end
end
PortalPolicy.prepend_mod_with('PortalPolicy')
diff --git a/app/presenters/mail_presenter.rb b/app/presenters/mail_presenter.rb
index 890e97a78..e57831c96 100644
--- a/app/presenters/mail_presenter.rb
+++ b/app/presenters/mail_presenter.rb
@@ -157,6 +157,10 @@ class MailPresenter < SimpleDelegator
auto_submitted? || x_auto_reply?
end
+ def bounced?
+ @mail.bounced? || @mail['X-Failed-Recipients'].try(:value).present?
+ end
+
def notification_email_from_chatwoot?
# notification emails are send via mailer sender email address. so it should match
original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot
')).address
diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
index 5e267f60c..37020b5dd 100644
--- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder
+++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
@@ -34,3 +34,10 @@ json.meta do
json.categories_count portal.categories.try(:size)
json.default_locale portal.default_locale
end
+
+if portal.ssl_settings.present?
+ json.ssl_settings do
+ json.status portal.ssl_settings['cf_status']
+ json.verification_errors portal.ssl_settings['cf_verification_errors']
+ end
+end
diff --git a/app/views/mailers/portal_instructions_mailer/send_cname_instructions.liquid b/app/views/mailers/portal_instructions_mailer/send_cname_instructions.liquid
new file mode 100644
index 000000000..b14ca1098
--- /dev/null
+++ b/app/views/mailers/portal_instructions_mailer/send_cname_instructions.liquid
@@ -0,0 +1,30 @@
+
+ |
+ Hello there,
+ To complete the setup of your help center, you'll need to update the DNS settings for your custom domain: {{ cname_record | split: ' ' | first }}.
+ Please add the following CNAME record to your DNS provider's configuration:
+ |
+
+
+
+ |
+ {{ cname_record }}
+ |
+
+
+
+ |
+ Step-by-step Instructions:
+
+
+ - Log in to your DNS provider’s dashboard
+ - Go to the DNS management section
+ - Create a new CNAME record using the information above
+ - Save the changes and allow up to 24 hours for the DNS to propagate
+
+
+ Once the DNS record is live, your custom domain will automatically be secured with an SSL certificate.
+
+ If you have any questions or need help, feel free to reach out to our support team—we’re here to assist you.
+ |
+
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 221f83472..9faa0cd9d 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -358,3 +358,12 @@ en:
Transcript:
%{format_messages}
+ portals:
+ send_instructions:
+ email_required: 'Email is required'
+ invalid_email_format: 'Invalid email format'
+ custom_domain_not_configured: 'Custom domain is not configured'
+ instructions_sent_successfully: 'Instructions sent successfully'
+ subject: 'Finish setting up %{custom_domain}'
+ ssl_status:
+ custom_domain_not_configured: 'Custom domain is not configured'
diff --git a/config/routes.rb b/config/routes.rb
index c12aa670b..7fb348084 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -290,6 +290,8 @@ Rails.application.routes.draw do
member do
patch :archive
delete :logo
+ post :send_instructions
+ get :ssl_status
end
resources :categories
resources :articles do
diff --git a/deployment/setup_20.04.sh b/deployment/setup_20.04.sh
index e904441a9..5a40ee068 100644
--- a/deployment/setup_20.04.sh
+++ b/deployment/setup_20.04.sh
@@ -2,7 +2,7 @@
# Description: Install and manage a Chatwoot installation.
# OS: Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS
-# Script Version: 3.4.0
+# Script Version: 3.4.2
# Run this script as root
set -eu -o errexit -o pipefail -o noclobber -o nounset
@@ -990,7 +990,7 @@ EOF
# Check if CW_VERSION is 4.0 or above
if [[ "$(printf '%s\n' "$CW_VERSION" "4.0" | sort -V | head -n 1)" == "4.0" ]]; then
echo "Chatwoot v4.0 and above requires pgvector support in PostgreSQL."
- read -p "Does your postgres support pgvector and want to proceed with the upgrade? [Y/n]: " user_input
+ read -p "Does your postgres support pgvector and want to proceed with the upgrade? [y/N]: " user_input
user_input=${user_input:-Y}
if [[ "$user_input" =~ ^([yY][eE][sS]|[yY])$ ]]; then
echo "Proceeding with the upgrade..."
@@ -1005,7 +1005,8 @@ EOF
upgrade_redis
upgrade_node
get_pnpm
- sudo -i -u chatwoot << "EOF"
+
+ sudo -i -u chatwoot << EOF
# Navigate to the Chatwoot directory
cd chatwoot
@@ -1016,9 +1017,9 @@ EOF
# Ensure the ruby version is upto date
# Parse the latest ruby version
- latest_ruby_version="$(cat '.ruby-version')"
- rvm install "ruby-$latest_ruby_version"
- rvm use "$latest_ruby_version" --default
+ latest_ruby_version="\$(cat '.ruby-version')"
+ rvm install "ruby-\$latest_ruby_version"
+ rvm use "\$latest_ruby_version" --default
# Update dependencies
bundle
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/portals_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/portals_controller.rb
new file mode 100644
index 000000000..488f7e700
--- /dev/null
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/portals_controller.rb
@@ -0,0 +1,15 @@
+module Enterprise::Api::V1::Accounts::PortalsController
+ def ssl_status
+ return render_could_not_create_error(I18n.t('portals.ssl_status.custom_domain_not_configured')) if @portal.custom_domain.blank?
+
+ result = Cloudflare::CheckCustomHostnameService.new(portal: @portal).perform
+
+ return render_could_not_create_error(result[:errors]) if result[:errors].present?
+
+ ssl_settings = @portal.ssl_settings || {}
+ render json: {
+ status: ssl_settings['cf_status'],
+ verification_errors: ssl_settings['cf_verification_errors']
+ }
+ end
+end
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/app/services/cloudflare/base_cloudflare_zone_service.rb b/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb
index 7fad50790..162f61915 100644
--- a/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb
+++ b/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb
@@ -17,4 +17,25 @@ class Cloudflare::BaseCloudflareZoneService
def zone_id
InstallationConfig.find_by(name: 'CLOUDFLARE_ZONE_ID')&.value
end
+
+ def update_portal_ssl_settings(portal, data)
+ verification_record = data['ownership_verification_http']
+ ssl_record = data['ssl']
+ verification_errors = data['verification_errors']&.first || ''
+
+ # Start with existing settings to preserve verification data if it exists
+ ssl_settings = portal.ssl_settings || {}
+
+ # Only update verification fields if they exist in the response (during initial setup)
+ if verification_record.present?
+ ssl_settings['cf_verification_id'] = verification_record['http_url'].split('/').last
+ ssl_settings['cf_verification_body'] = verification_record['http_body']
+ end
+
+ # Always update SSL status and errors from current response
+ ssl_settings['cf_status'] = ssl_record&.dig('status')
+ ssl_settings['cf_verification_errors'] = verification_errors
+
+ portal.update(ssl_settings: ssl_settings)
+ end
end
diff --git a/enterprise/app/services/cloudflare/check_custom_hostname_service.rb b/enterprise/app/services/cloudflare/check_custom_hostname_service.rb
index 588a9c4a7..716623a18 100644
--- a/enterprise/app/services/cloudflare/check_custom_hostname_service.rb
+++ b/enterprise/app/services/cloudflare/check_custom_hostname_service.rb
@@ -14,21 +14,10 @@ class Cloudflare::CheckCustomHostnameService < Cloudflare::BaseCloudflareZoneSer
data = response.parsed_response['result']
if data.present?
- update_portal_ssl_settings(data.first)
+ update_portal_ssl_settings(@portal, data.first)
return { data: data }
end
{ errors: ['Hostname is missing in Cloudflare'] }
end
-
- private
-
- def update_portal_ssl_settings(data)
- verification_record = data['ownership_verification_http']
- ssl_settings = {
- 'cf_verification_id': verification_record['http_url'].split('/').last,
- 'cf_verification_body': verification_record['http_body']
- }
- @portal.update(ssl_settings: ssl_settings)
- end
end
diff --git a/enterprise/app/services/cloudflare/create_custom_hostname_service.rb b/enterprise/app/services/cloudflare/create_custom_hostname_service.rb
index f1546caed..236b434e5 100644
--- a/enterprise/app/services/cloudflare/create_custom_hostname_service.rb
+++ b/enterprise/app/services/cloudflare/create_custom_hostname_service.rb
@@ -12,7 +12,7 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe
data = response.parsed_response['result']
if data.present?
- update_portal_ssl_settings(data)
+ update_portal_ssl_settings(@portal, data)
return { data: data }
end
@@ -25,16 +25,13 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe
HTTParty.post(
"#{BASE_URI}/zones/#{zone_id}/custom_hostnames",
headers: headers,
- body: { hostname: @portal.custom_domain }.to_json
+ body: {
+ hostname: @portal.custom_domain,
+ ssl: {
+ method: 'http',
+ type: 'dv'
+ }
+ }.to_json
)
end
-
- def update_portal_ssl_settings(data)
- verification_record = data['ownership_verification_http']
- ssl_settings = {
- 'cf_verification_id': verification_record['http_url'].split('/').last,
- 'cf_verification_body': verification_record['http_body']
- }
- @portal.update(ssl_settings: ssl_settings)
- end
end
diff --git a/enterprise/app/services/llm/base_open_ai_service.rb b/enterprise/app/services/llm/base_open_ai_service.rb
index 04909cbf4..2d3932246 100644
--- a/enterprise/app/services/llm/base_open_ai_service.rb
+++ b/enterprise/app/services/llm/base_open_ai_service.rb
@@ -15,7 +15,8 @@ class Llm::BaseOpenAiService
private
def uri_base
- InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
+ endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
+ endpoint.presence || 'https://api.openai.com/'
end
def setup_model
diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder
index dc5860fb9..0b137d822 100644
--- a/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder
@@ -1,5 +1,10 @@
-json.data do
+json.payload do
json.array! @scenarios do |scenario|
json.partial! 'api/v1/models/captain/scenario', scenario: scenario
end
end
+
+json.meta do
+ json.total_count @scenarios.count
+ json.page 1
+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/package.json b/package.json
index e0fd2cf7b..2aefb3d53 100644
--- a/package.json
+++ b/package.json
@@ -33,8 +33,8 @@
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
- "@chatwoot/prosemirror-schema": "1.1.6-next",
- "@chatwoot/utils": "^0.0.47",
+ "@chatwoot/prosemirror-schema": "1.2.1",
+ "@chatwoot/utils": "^0.0.49",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
@@ -93,7 +93,7 @@
"vue-chartjs": "5.3.1",
"vue-datepicker-next": "^1.0.3",
"vue-dompurify-html": "^5.1.0",
- "vue-i18n": "9.14.3",
+ "vue-i18n": "9.14.5",
"vue-letter": "^0.2.1",
"vue-multiselect": "3.1.0",
"vue-router": "~4.4.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a88cfc084..ca55fbe34 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,11 +20,11 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.1.6-next
- version: 1.1.6-next
+ specifier: 1.2.1
+ version: 1.2.1
'@chatwoot/utils':
- specifier: ^0.0.47
- version: 0.0.47
+ specifier: ^0.0.49
+ version: 0.0.49
'@formkit/core':
specifier: ^1.6.7
version: 1.6.7
@@ -200,8 +200,8 @@ importers:
specifier: ^5.1.0
version: 5.1.0(vue@3.5.12(typescript@5.6.2))
vue-i18n:
- specifier: 9.14.3
- version: 9.14.3(vue@3.5.12(typescript@5.6.2))
+ specifier: 9.14.5
+ version: 9.14.5(vue@3.5.12(typescript@5.6.2))
vue-letter:
specifier: ^0.2.1
version: 0.2.1
@@ -403,11 +403,11 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.1.6-next':
- resolution: {integrity: sha512-9lf7FrcED/B5oyGrMmIkbegkhlC/P0NrtXoX8k94YWRosZcx0hGVGhpTud+0Mhm7saAfGerKIwTRVDmmnxPuCA==}
+ '@chatwoot/prosemirror-schema@1.2.1':
+ resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==}
- '@chatwoot/utils@0.0.47':
- resolution: {integrity: sha512-0z/MY+rBjDnf6zuWbMdzexH+zFDXU/g5fPr/kcUxnqtvPsZIQpL8PvwSPBW0+wS6R7LChndNkdviV1e9H8Yp+Q==}
+ '@chatwoot/utils@0.0.49':
+ resolution: {integrity: sha512-Co68VzaFtctTNYKY6y4izBBATvk6/8ZVtkyEP5HL72uhFDA11LrY5pqSh04HMoFyfdIU+uVPimfI45HAeso1IA==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -906,8 +906,8 @@ packages:
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
engines: {node: '>= 16'}
- '@intlify/core-base@9.14.3':
- resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
+ '@intlify/core-base@9.14.5':
+ resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
engines: {node: '>= 16'}
'@intlify/eslint-plugin-vue-i18n@3.2.0':
@@ -920,16 +920,16 @@ packages:
resolution: {integrity: sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ==}
engines: {node: '>= 16'}
- '@intlify/message-compiler@9.14.3':
- resolution: {integrity: sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==}
+ '@intlify/message-compiler@9.14.5':
+ resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
engines: {node: '>= 16'}
'@intlify/shared@9.14.2':
resolution: {integrity: sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw==}
engines: {node: '>= 16'}
- '@intlify/shared@9.14.3':
- resolution: {integrity: sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==}
+ '@intlify/shared@9.14.5':
+ resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
engines: {node: '>= 16'}
'@isaacs/cliui@8.0.2':
@@ -940,6 +940,9 @@ packages:
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
+ '@jridgewell/gen-mapping@0.3.12':
+ resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
+
'@jridgewell/gen-mapping@0.3.5':
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
engines: {node: '>=6.0.0'}
@@ -952,19 +955,29 @@ packages:
resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==}
engines: {node: '>=6.0.0'}
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
'@jridgewell/set-array@1.2.1':
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
- '@jridgewell/source-map@0.3.6':
- resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
+ '@jridgewell/source-map@0.3.10':
+ resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
+ '@jridgewell/sourcemap-codec@1.5.4':
+ resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
+
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+ '@jridgewell/trace-mapping@0.3.29':
+ resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
+
'@june-so/analytics-next@2.0.0':
resolution: {integrity: sha512-7uFP94JLD7mP4qLyOwn5HBs+CC8VlevOkiGd1CIYqPSjSRmbCOI+MVcJNlTAcpyNvMi9iUnWZ3jGVO5177Di4A==}
@@ -1993,8 +2006,8 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
- acorn@8.14.1:
- resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
+ acorn@8.15.0:
+ resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -4960,8 +4973,8 @@ packages:
peerDependencies:
eslint: '>=6.0.0'
- vue-i18n@9.14.3:
- resolution: {integrity: sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==}
+ vue-i18n@9.14.5:
+ resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
engines: {node: '>= 16'}
peerDependencies:
vue: ^3.0.0
@@ -5237,7 +5250,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.1.6-next':
+ '@chatwoot/prosemirror-schema@1.2.1':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
@@ -5255,7 +5268,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.49':
dependencies:
date-fns: 2.30.0
@@ -5778,10 +5791,10 @@ snapshots:
'@intlify/message-compiler': 9.14.2
'@intlify/shared': 9.14.2
- '@intlify/core-base@9.14.3':
+ '@intlify/core-base@9.14.5':
dependencies:
- '@intlify/message-compiler': 9.14.3
- '@intlify/shared': 9.14.3
+ '@intlify/message-compiler': 9.14.5
+ '@intlify/shared': 9.14.5
'@intlify/eslint-plugin-vue-i18n@3.2.0(eslint@8.57.0)':
dependencies:
@@ -5813,14 +5826,14 @@ snapshots:
'@intlify/shared': 9.14.2
source-map-js: 1.2.1
- '@intlify/message-compiler@9.14.3':
+ '@intlify/message-compiler@9.14.5':
dependencies:
- '@intlify/shared': 9.14.3
+ '@intlify/shared': 9.14.5
source-map-js: 1.2.1
'@intlify/shared@9.14.2': {}
- '@intlify/shared@9.14.3': {}
+ '@intlify/shared@9.14.5': {}
'@isaacs/cliui@8.0.2':
dependencies:
@@ -5833,6 +5846,12 @@ snapshots:
'@istanbuljs/schema@0.1.3': {}
+ '@jridgewell/gen-mapping@0.3.12':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.4
+ '@jridgewell/trace-mapping': 0.3.29
+ optional: true
+
'@jridgewell/gen-mapping@0.3.5':
dependencies:
'@jridgewell/set-array': 1.2.1
@@ -5847,21 +5866,33 @@ snapshots:
'@jridgewell/resolve-uri@3.1.1': {}
+ '@jridgewell/resolve-uri@3.1.2':
+ optional: true
+
'@jridgewell/set-array@1.2.1': {}
- '@jridgewell/source-map@0.3.6':
+ '@jridgewell/source-map@0.3.10':
dependencies:
- '@jridgewell/gen-mapping': 0.3.8
- '@jridgewell/trace-mapping': 0.3.25
+ '@jridgewell/gen-mapping': 0.3.12
+ '@jridgewell/trace-mapping': 0.3.29
optional: true
'@jridgewell/sourcemap-codec@1.5.0': {}
+ '@jridgewell/sourcemap-codec@1.5.4':
+ optional: true
+
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/trace-mapping@0.3.29':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.4
+ optional: true
+
'@june-so/analytics-next@2.0.0':
dependencies:
'@lukeed/uuid': 2.0.0
@@ -7078,7 +7109,7 @@ snapshots:
acorn@8.14.0: {}
- acorn@8.14.1:
+ acorn@8.15.0:
optional: true
activestorage@5.2.8:
@@ -10124,8 +10155,8 @@ snapshots:
terser@5.33.0:
dependencies:
- '@jridgewell/source-map': 0.3.6
- acorn: 8.14.1
+ '@jridgewell/source-map': 0.3.10
+ acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
optional: true
@@ -10479,10 +10510,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- vue-i18n@9.14.3(vue@3.5.12(typescript@5.6.2)):
+ vue-i18n@9.14.5(vue@3.5.12(typescript@5.6.2)):
dependencies:
- '@intlify/core-base': 9.14.3
- '@intlify/shared': 9.14.3
+ '@intlify/core-base': 9.14.5
+ '@intlify/shared': 9.14.5
'@vue/devtools-api': 6.6.4
vue: 3.5.12(typescript@5.6.2)
diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
index aeec9cab4..d0ea13e2b 100644
--- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
@@ -210,4 +210,76 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
end
end
end
+
+ describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/send_instructions' do
+ let(:portal_with_domain) { create(:portal, slug: 'portal-with-domain', account_id: account.id, custom_domain: 'docs.example.com') }
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
+ params: { email: 'dev@example.com' }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated agent' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
+ headers: agent.create_new_auth_token,
+ params: { email: 'dev@example.com' },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated admin' do
+ it 'returns error when email is missing' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
+ headers: admin.create_new_auth_token,
+ params: {},
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Email is required')
+ end
+
+ it 'returns error when email is invalid' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
+ headers: admin.create_new_auth_token,
+ params: { email: 'invalid-email' },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Invalid email format')
+ end
+
+ it 'returns error when custom domain is not configured' do
+ post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/send_instructions",
+ headers: admin.create_new_auth_token,
+ params: { email: 'dev@example.com' },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Custom domain is not configured')
+ end
+
+ it 'sends instructions successfully' do
+ mailer_double = instance_double(ActionMailer::MessageDelivery)
+ allow(PortalInstructionsMailer).to receive(:send_cname_instructions).and_return(mailer_double)
+ allow(mailer_double).to receive(:deliver_later)
+
+ post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
+ headers: admin.create_new_auth_token,
+ params: { email: 'dev@example.com' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['message']).to eq('Instructions sent successfully')
+ expect(PortalInstructionsMailer).to have_received(:send_cname_instructions)
+ .with(portal: portal_with_domain, recipient_email: 'dev@example.com')
+ end
+ end
+ end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb
index ed223622b..3e68c9e5e 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
as: :json
expect(response).to have_http_status(:success)
- expect(json_response[:data].length).to eq(3)
+ expect(json_response[:payload].length).to eq(3)
end
end
@@ -38,7 +38,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
as: :json
expect(response).to have_http_status(:success)
- expect(json_response[:data].length).to eq(5)
+ expect(json_response[:payload].length).to eq(5)
end
it 'returns only enabled scenarios' do
@@ -49,8 +49,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
as: :json
expect(response).to have_http_status(:success)
- expect(json_response[:data].length).to eq(1)
- expect(json_response[:data].first[:enabled]).to be(true)
+ expect(json_response[:payload].length).to eq(1)
+ expect(json_response[:payload].first[:enabled]).to be(true)
end
end
end
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
index cb296494c..48e6c9e00 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/portals_controller_spec.rb
@@ -87,4 +87,73 @@ RSpec.describe 'Enterprise Portal API', type: :request do
end
end
end
+
+ describe 'GET /api/v1/accounts/{account.id}/portals/{portal.slug}/ssl_status' do
+ let(:portal_with_domain) { create(:portal, slug: 'portal-with-domain', account_id: account.id, custom_domain: 'docs.example.com') }
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ it 'returns error when custom domain is not configured' do
+ get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/ssl_status",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Custom domain is not configured')
+ end
+
+ it 'returns SSL status when portal has ssl_settings' do
+ portal_with_domain.update(ssl_settings: {
+ 'cf_status' => 'active',
+ 'cf_verification_errors' => nil
+ })
+
+ mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
+ allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
+ allow(mock_service).to receive(:perform).and_return({ data: [] })
+
+ get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['status']).to eq('active')
+ expect(response.parsed_body['verification_errors']).to be_nil
+ end
+
+ it 'returns null values when portal has no ssl_settings' do
+ mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
+ allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
+ allow(mock_service).to receive(:perform).and_return({ data: [] })
+
+ get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['status']).to be_nil
+ expect(response.parsed_body['verification_errors']).to be_nil
+ end
+
+ it 'returns error when Cloudflare service returns errors' do
+ mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
+ allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
+ allow(mock_service).to receive(:perform).and_return({ errors: ['API token not found'] })
+
+ get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq(['API token not found'])
+ end
+ end
+ end
end
diff --git a/spec/enterprise/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb b/spec/enterprise/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb
new file mode 100644
index 000000000..ef5b9b0c7
--- /dev/null
+++ b/spec/enterprise/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb
@@ -0,0 +1,26 @@
+require 'rails_helper'
+
+RSpec.describe Inboxes::FetchImapEmailInboxesJob do
+ context 'when chatwoot_cloud is enabled' do
+ let(:account) { create(:account) }
+ let(:premium_account) { create(:account, custom_attributes: { plan_name: 'Startups' }) }
+ let(:imap_email_channel) { create(:channel_email, imap_enabled: true, account: account) }
+ let(:premium_imap_channel) { create(:channel_email, imap_enabled: true, account: premium_account) }
+
+ before do
+ premium_account.custom_attributes['plan_name'] = 'Startups'
+ InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create!(value: 'cloud')
+ InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create!(value: [{ 'name' => 'Hacker' }])
+ end
+
+ it 'skips inboxes with default plan' do
+ expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel)
+ described_class.perform_now
+ end
+
+ it 'processes inboxes with premium plan' do
+ expect(Inboxes::FetchImapEmailsJob).to receive(:perform_later).with(premium_imap_channel)
+ described_class.perform_now
+ end
+ end
+end
diff --git a/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb b/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb
index d7ed80b90..b7e567465 100644
--- a/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb
+++ b/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb
@@ -96,8 +96,10 @@ RSpec.describe Cloudflare::CheckCustomHostnameService do
expect(portal).to receive(:update).with(
ssl_settings: {
- 'cf_verification_id': 'verification-id',
- 'cf_verification_body': 'verification-body'
+ 'cf_verification_id' => 'verification-id',
+ 'cf_verification_body' => 'verification-body',
+ 'cf_status' => nil,
+ 'cf_verification_errors' => ''
}
)
diff --git a/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb b/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb
index a3ddc8273..3f49c96dd 100644
--- a/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb
+++ b/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb
@@ -54,7 +54,7 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
- body: { hostname: 'test.example.com' }.to_json)
+ body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
@@ -72,7 +72,7 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
- body: { hostname: 'test.example.com' }.to_json)
+ body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
@@ -92,17 +92,22 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
}
}
}
+ expect(portal.ssl_settings).to eq({})
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
- body: { hostname: 'test.example.com' }.to_json)
+ body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
- expect(portal).to receive(:update).with(ssl_settings: { 'cf_verification_id': 'verification-id',
- 'cf_verification_body': 'verification-body' })
-
result = service.perform
-
+ expect(portal.ssl_settings).to eq(
+ {
+ 'cf_verification_id' => 'verification-id',
+ 'cf_verification_body' => 'verification-body',
+ 'cf_status' => nil,
+ 'cf_verification_errors' => ''
+ }
+ )
expect(result).to eq(data: success_response['result'])
end
end
diff --git a/spec/fixtures/files/bounced_gmail.eml b/spec/fixtures/files/bounced_gmail.eml
new file mode 100644
index 000000000..2c45bcd4d
--- /dev/null
+++ b/spec/fixtures/files/bounced_gmail.eml
@@ -0,0 +1,120 @@
+Delivered-To: robert.smith@gmail.com
+Return-Path: <>
+Subject: Delivery Status Notification (Failure)
+From: Mail Delivery Subsystem
+To: robert.smith@gmail.com
+Content-Type: multipart/report; boundary="00000000000093475906390e1e9b"; report-type=delivery-status
+Auto-Submitted: auto-replied
+Message-ID: <686707c9.050a0220.302e7d.0cb2.GMR@mx.google.com>
+Date: Thu, 03 Jul 2025 15:44:25 -0700 (PDT)
+X-Failed-Recipients: alex.jones@fictionalcorp.com
+
+--00000000000093475906390e1e9b
+Content-Type: multipart/related; boundary="000000000000936d8406390e1ec7"
+
+--000000000000936d8406390e1ec7
+Content-Type: multipart/alternative; boundary="000000000000936d9006390e1ec8"
+
+--000000000000936d9006390e1ec8
+Content-Type: text/plain; charset="UTF-8"
+Content-Transfer-Encoding: quoted-printable
+
+
+** Address not found **
+
+Your message wasn't delivered to alex.jones@fictionalcorp.com because the address co=
+uldn't be found or is unable to receive email.
+
+Learn more here: https://support.google.com/mail/?p=3DNoSuchUser
+
+The response was:
+
+550 5.1.1 The email account that you tried to reach does not exist. Please =
+try double-checking the recipient's email address for typos or unnecessary =
+spaces. For more information, go to https://support.google.com/mail/?p=3DNo=
+SuchUser d2e1a72fcca58-74ce2b0525csor332154b3a.0 - gsmtp
+
+--000000000000936d9006390e1ec8
+Content-Type: text/html; charset="UTF-8"
+Content-Transfer-Encoding: quoted-printable
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ |
+
+
+The response was:
+
+550 5.1.1 The email account that you tried to reach does not exist. Please =
+try double-checking the recipient's email address for typos or unnecessary =
+spaces. For more information, go to https://support.google.com/mail/?p=3DNo=
+SuchUser d2e1a72fcca58-74ce2b0525csor332154b3a.0 - gsmtp
+
+ |
+
+
+
+
+
+--000000000000936d9006390e1ec8--
+--000000000000936d8406390e1ec7
+Content-Type: image/png; name="icon.png"
+Content-Disposition: attachment; filename="icon.png"
+Content-Transfer-Encoding: base64
+Content-ID:
+
+--000000000000936d8406390e1ec7--
+--00000000000093475906390e1e9b
+Content-Type: message/delivery-status
+
+--00000000000093475906390e1e9b
+Content-Type: message/rfc822
+
+Date: Thu, 03 Jul 2025 15:44:23 -0700
+From: Robert Smith
+Reply-To: robert.smith@gmail.com
+To: alex.jones@fictionalcorp.com
+Message-ID:
+In-Reply-To:
+Subject: Just checking in
+Mime-Version: 1.0
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+Hey, just checking in. Let me know if you got my earlier message.
+
+--00000000000093475906390e1e9b--
diff --git a/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb b/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb
index 18685a649..abcab1e8f 100644
--- a/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb
+++ b/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb
@@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
let(:account) { create(:account) }
let(:suspended_account) { create(:account, status: 'suspended') }
+ let(:premium_account) { create(:account, custom_attributes: { plan_name: 'Startups' }) }
let(:imap_email_channel) do
create(:channel_email, imap_enabled: true, account: account)
@@ -16,6 +17,19 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
create(:channel_email, imap_enabled: false, account: account)
end
+ let(:reauth_required_channel) do
+ create(:channel_email, imap_enabled: true, account: account)
+ end
+
+ let(:premium_imap_channel) do
+ create(:channel_email, imap_enabled: true, account: premium_account)
+ end
+
+ before do
+ reauth_required_channel.prompt_reauthorization!
+ premium_account.custom_attributes['plan_name'] = 'Startups'
+ end
+
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
@@ -44,5 +58,11 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
described_class.perform_now
end
+
+ it 'skips channels requiring reauthorization' do
+ expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(reauth_required_channel)
+
+ described_class.perform_now
+ end
end
end
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
diff --git a/spec/mailboxes/imap/imap_mailbox_spec.rb b/spec/mailboxes/imap/imap_mailbox_spec.rb
index cc72be18b..fc94c98be 100644
--- a/spec/mailboxes/imap/imap_mailbox_spec.rb
+++ b/spec/mailboxes/imap/imap_mailbox_spec.rb
@@ -115,6 +115,14 @@ RSpec.describe Imap::ImapMailbox do
end
end
+ context 'when the email is bounced' do
+ let!(:bounced_mail) { create_inbound_email_from_fixture('bounced_gmail.eml') }
+
+ it 'processes the bounced email' do
+ expect { class_instance.process(bounced_mail.mail, channel) }.to change(Message, :count)
+ end
+ end
+
context 'when a reply for existing email conversation' do
let(:prev_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
let(:reply_mail) do
diff --git a/spec/mailers/portal_instructions_mailer_spec.rb b/spec/mailers/portal_instructions_mailer_spec.rb
new file mode 100644
index 000000000..bfad55a08
--- /dev/null
+++ b/spec/mailers/portal_instructions_mailer_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe PortalInstructionsMailer do
+ describe 'send_cname_instructions' do
+ let!(:account) { create(:account) }
+ let!(:portal) { create(:portal, account: account, custom_domain: 'help.example.com') }
+ let(:recipient_email) { 'admin@example.com' }
+ let(:class_instance) { described_class.new }
+
+ before do
+ allow(described_class).to receive(:new).and_return(class_instance)
+ allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
+ end
+
+ context 'when target domain is available' do
+ it 'sends email with cname instructions' do
+ with_modified_env HELPCENTER_URL: 'https://help.chatwoot.com' do
+ mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
+
+ expect(mail.to).to eq([recipient_email])
+ expect(mail.subject).to eq("Finish setting up #{portal.custom_domain}")
+ expect(mail.body.encoded).to include('help.example.com CNAME help.chatwoot.com')
+ end
+ end
+ end
+
+ context 'when helpcenter url is not available but frontend url is' do
+ it 'uses frontend url as target domain' do
+ with_modified_env HELPCENTER_URL: '', FRONTEND_URL: 'https://app.chatwoot.com' do
+ mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
+
+ expect(mail.to).to eq([recipient_email])
+ expect(mail.body.encoded).to include('help.example.com CNAME app.chatwoot.com')
+ end
+ end
+ end
+
+ context 'when no target domain is available' do
+ it 'does not send email' do
+ with_modified_env HELPCENTER_URL: '', FRONTEND_URL: '' do
+ mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
+
+ expect(mail).to be_nil
+ end
+ end
+ end
+ end
+end