-
-
-
-
-
- {{ $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/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/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/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/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 1c951cbe3..e57831c96 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)
@@ -150,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 b21a22dcd..9e47cd011 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -362,3 +362,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 f659f6443..1c45844fd 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -298,6 +298,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/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/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/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/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
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 }