diff --git a/Gemfile b/Gemfile index 265c609c1..18442e3b0 100644 --- a/Gemfile +++ b/Gemfile @@ -103,7 +103,7 @@ gem 'twitty', '~> 0.1.5' # facebook client gem 'koala' # slack client -gem 'slack-ruby-client', '~> 2.5.2' +gem 'slack-ruby-client', '~> 2.7.0' # for dialogflow integrations gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'grpc' diff --git a/Gemfile.lock b/Gemfile.lock index 16e57d4f8..283cf3b83 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -292,7 +292,7 @@ GEM logger faraday-follow_redirects (0.3.0) faraday (>= 1, < 3) - faraday-mashify (0.1.1) + faraday-mashify (1.0.0) faraday (~> 2.0) hashie faraday-multipart (1.0.4) @@ -876,8 +876,8 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - slack-ruby-client (2.5.2) - faraday (>= 2.0) + slack-ruby-client (2.7.0) + faraday (>= 2.0.1) faraday-mashify faraday-multipart gli @@ -935,7 +935,7 @@ GEM unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uniform_notifier (1.17.0) - uri (1.0.3) + uri (1.0.4) uri_template (0.7.0) valid_email2 (5.2.6) activemodel (>= 3.2) @@ -1103,7 +1103,7 @@ DEPENDENCIES sidekiq_alive simplecov (>= 0.21) simplecov_json_formatter - slack-ruby-client (~> 2.5.2) + slack-ruby-client (~> 2.7.0) spring spring-watcher-listen squasher diff --git a/app/builders/email/base_builder.rb b/app/builders/email/base_builder.rb new file mode 100644 index 000000000..731b1b0f5 --- /dev/null +++ b/app/builders/email/base_builder.rb @@ -0,0 +1,54 @@ +class Email::BaseBuilder + pattr_initialize [:inbox!] + + private + + def channel + @channel ||= inbox.channel + end + + def account + @account ||= inbox.account + end + + def conversation + @conversation ||= message.conversation + end + + def custom_sender_name + message&.sender&.available_name || I18n.t('conversations.reply.email.header.notifications') + end + + def sender_name(sender_email) + # Friendly: from + # Professional: + if inbox.friendly? + I18n.t( + 'conversations.reply.email.header.friendly_name', + sender_name: custom_sender_name, + business_name: business_name, + from_email: sender_email + ) + else + I18n.t( + 'conversations.reply.email.header.professional_name', + business_name: business_name, + from_email: sender_email + ) + end + end + + def business_name + inbox.business_name || inbox.sanitized_name + end + + def account_support_email + # Parse the email to ensure it's in the correct format, the user + # can save it in the format "Name " + parse_email(account.support_email) + end + + def parse_email(email_string) + Mail::Address.new(email_string).address + end +end diff --git a/app/builders/email/from_builder.rb b/app/builders/email/from_builder.rb new file mode 100644 index 000000000..fff33dc0a --- /dev/null +++ b/app/builders/email/from_builder.rb @@ -0,0 +1,51 @@ +class Email::FromBuilder < Email::BaseBuilder + pattr_initialize [:inbox!, :message!] + + def build + return sender_name(account_support_email) unless inbox.email? + + from_email = case email_channel_type + when :standard_imap_smtp, + :google_oauth, + :microsoft_oauth, + :forwarding_own_smtp + channel.email + when :imap_chatwoot_smtp, + :forwarding_chatwoot_smtp + channel.verified_for_sending ? channel.email : account_support_email + else + account_support_email + end + + sender_name(from_email) + end + + private + + def email_channel_type + return :google_oauth if channel.google? + return :microsoft_oauth if channel.microsoft? + return :standard_imap_smtp if imap_and_smtp_enabled? + return :imap_chatwoot_smtp if imap_enabled_without_smtp? + return :forwarding_own_smtp if forwarding_with_own_smtp? + return :forwarding_chatwoot_smtp if forwarding_without_smtp? + + :unknown + end + + def imap_and_smtp_enabled? + channel.imap_enabled && channel.smtp_enabled + end + + def imap_enabled_without_smtp? + channel.imap_enabled && !channel.smtp_enabled + end + + def forwarding_with_own_smtp? + !channel.imap_enabled && channel.smtp_enabled + end + + def forwarding_without_smtp? + !channel.imap_enabled && !channel.smtp_enabled + end +end diff --git a/app/builders/email/reply_to_builder.rb b/app/builders/email/reply_to_builder.rb new file mode 100644 index 000000000..d330c922a --- /dev/null +++ b/app/builders/email/reply_to_builder.rb @@ -0,0 +1,21 @@ +class Email::ReplyToBuilder < Email::BaseBuilder + pattr_initialize [:inbox!, :message!] + + def build + reply_to = if inbox.email? + channel.email + elsif inbound_email_enabled? + "reply+#{conversation.uuid}@#{account.inbound_email_domain}" + else + account_support_email + end + + sender_name(reply_to) + end + + private + + def inbound_email_enabled? + account.feature_enabled?('inbound_emails') && account.inbound_email_domain.present? + end +end diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index e1087b19f..86bcee54e 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -7,6 +7,7 @@ class Messages::MessageBuilder @private = params[:private] || false @conversation = conversation @user = user + @account = conversation.account @message_type = params[:message_type] || 'outgoing' @attachments = params[:attachments] @automation_rule = content_attributes&.dig(:automation_rule_id) @@ -20,6 +21,9 @@ class Messages::MessageBuilder @message = @conversation.messages.build(message_params) process_attachments process_emails + # When the message has no quoted content, it will just be rendered as a regular message + # The frontend is equipped to handle this case + process_email_content if @account.feature_enabled?(:quoted_email_reply) @message.save! @message end @@ -92,6 +96,14 @@ class Messages::MessageBuilder @message.content_attributes[:to_emails] = to_emails end + def process_email_content + return unless should_process_email_content? + + @message.content_attributes ||= {} + email_attributes = build_email_attributes + @message.content_attributes[:email] = email_attributes + end + def process_email_string(email_string) return [] if email_string.blank? @@ -153,4 +165,52 @@ class Messages::MessageBuilder source_id: @params[:source_id] }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) end + + def email_inbox? + @conversation.inbox&.inbox_type == 'Email' + end + + def should_process_email_content? + email_inbox? && !@private && @message.content.present? + end + + def build_email_attributes + email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {}) + normalized_content = normalize_email_body(@message.content) + + email_attributes[:html_content] = build_html_content(normalized_content) + email_attributes[:text_content] = build_text_content(normalized_content) + email_attributes + end + + def build_html_content(normalized_content) + html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {}) + rendered_html = render_email_html(normalized_content) + html_content[:full] = rendered_html + html_content[:reply] = rendered_html + html_content + end + + def build_text_content(normalized_content) + text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {}) + text_content[:full] = normalized_content + text_content[:reply] = normalized_content + text_content + end + + def ensure_indifferent_access(hash) + return {} if hash.blank? + + hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash + end + + def normalize_email_body(content) + content.to_s.gsub("\r\n", "\n") + end + + def render_email_html(content) + return '' if content.blank? + + ChatwootMarkdownRenderer.new(content).render_message.to_s + end end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 039786905..e6270c807 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -17,8 +17,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] def index - @contacts_count = resolved_contacts.count @contacts = fetch_contacts(resolved_contacts) + @contacts_count = @contacts.total_count end def search @@ -29,8 +29,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController OR contacts.additional_attributes->>\'company_name\' ILIKE :search', search: "%#{params[:q].strip}%" ) - @contacts_count = contacts.count @contacts = fetch_contacts(contacts) + @contacts_count = @contacts.total_count end def import @@ -55,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def active contacts = Current.account.contacts.where(id: ::OnlineStatusTracker .get_available_contact_ids(Current.account.id)) - @contacts_count = contacts.count @contacts = fetch_contacts(contacts) + @contacts_count = @contacts.total_count end def show; end @@ -133,13 +133,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController end def fetch_contacts(contacts) - contacts_with_avatar = filtrate(contacts) - .includes([{ avatar_attachment: [:blob] }]) - .page(@current_page).per(RESULTS_PER_PAGE) + # Build includes hash to avoid separate query when contact_inboxes are needed + includes_hash = { avatar_attachment: [:blob] } + includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes - return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes - - contacts_with_avatar + filtrate(contacts) + .includes(includes_hash) + .page(@current_page) + .per(RESULTS_PER_PAGE) end def build_contact_inbox diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 4750e3b4a..ae1d4369a 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -4,7 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :validate_limit, only: [:create] # we are already handling the authorization in fetch inbox - before_action :check_authorization, except: [:show] + before_action :check_authorization, except: [:show, :health] + before_action :validate_whatsapp_cloud_channel, only: [:health] def index @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @@ -78,6 +79,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController render status: :internal_server_error, json: { error: e.message } end + def health + health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status + render json: health_data + rescue StandardError => e + Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}" + render json: { error: e.message }, status: :unprocessable_entity + end + private def fetch_inbox @@ -89,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] end + def validate_whatsapp_cloud_channel + return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud' + + render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request + end + def create_channel return unless allowed_channel_types.include?(permitted_params[:channel][:type]) diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index fd3dba87c..900125670 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -19,6 +19,19 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token) end + def sign_in_user_on_mobile + @resource.skip_confirmation! if confirmable_enabled? + + # once the resource is found and verified + # we can just send them to the login page again with the SSO params + # that will log them in + encoded_email = ERB::Util.url_encode(@resource.email) + params = { email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token }.to_query + + mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp') + redirect_to "#{mobile_deep_link_base}://auth/saml?#{params}", allow_other_host: true + end + def sign_up_user return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed? return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? diff --git a/app/controllers/super_admin/users_controller.rb b/app/controllers/super_admin/users_controller.rb index ff242030a..e98e61c95 100644 --- a/app/controllers/super_admin/users_controller.rb +++ b/app/controllers/super_admin/users_controller.rb @@ -13,11 +13,11 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController redirect_to new_super_admin_user_path, notice: notice end end - # - # def update - # super - # send_foo_updated_email(requested_resource) - # end + + def update + requested_resource.skip_reconfirmation! if resource_params[:confirmed_at].present? + super + end # Override this method to specify custom lookup behavior. # This will be used to set the resource for the `show`, `edit`, and `update` diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index 8abdefd1a..753b617ef 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -59,11 +59,11 @@ class UserDashboard < Administrate::BaseDashboard SHOW_PAGE_ATTRIBUTES = %i[ id avatar_url - unconfirmed_email name type display_name email + unconfirmed_email created_at updated_at confirmed_at diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js new file mode 100644 index 000000000..d0818d941 --- /dev/null +++ b/app/javascript/dashboard/api/captain/customTools.js @@ -0,0 +1,36 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainCustomTools extends ApiClient { + constructor() { + super('captain/custom_tools', { accountScoped: true }); + } + + get({ page = 1, searchKey } = {}) { + return axios.get(this.url, { + params: { page, searchKey }, + }); + } + + show(id) { + return axios.get(`${this.url}/${id}`); + } + + create(data = {}) { + return axios.post(this.url, { + custom_tool: data, + }); + } + + update(id, data = {}) { + return axios.put(`${this.url}/${id}`, { + custom_tool: data, + }); + } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } +} + +export default new CaptainCustomTools(); diff --git a/app/javascript/dashboard/api/inboxHealth.js b/app/javascript/dashboard/api/inboxHealth.js new file mode 100644 index 000000000..181b041ba --- /dev/null +++ b/app/javascript/dashboard/api/inboxHealth.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InboxHealthAPI extends ApiClient { + constructor() { + super('inboxes', { accountScoped: true }); + } + + getHealthStatus(inboxId) { + return axios.get(`${this.url}/${inboxId}/health`); + } +} + +export default new InboxHealthAPI(); diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 41732ac2b..4d6d41dac 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -148,10 +148,21 @@ const isAnyDropdownActive = computed(() => { const handleContactSearch = value => { showContactsDropdown.value = true; - emit('searchContacts', { - keys: ['email', 'phone_number', 'name'], - query: value, + const query = typeof value === 'string' ? value.trim() : ''; + const hasAlphabet = Array.from(query).some(char => { + const lower = char.toLowerCase(); + const upper = char.toUpperCase(); + return lower !== upper; }); + const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query); + + const keys = ['email', 'phone_number', 'name'].filter(key => { + if (key === 'phone_number' && hasAlphabet) return false; + if (key === 'name' && isEmailLike) return false; + return true; + }); + + emit('searchContacts', { keys, query: value }); }; const handleDropdownUpdate = (type, value) => { diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue b/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue index 31e18394f..8d67344e1 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue @@ -10,6 +10,10 @@ const props = defineProps({ type: String, required: true, }, + translationKey: { + type: String, + required: true, + }, entity: { type: Object, required: true, @@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']); const { t } = useI18n(); const store = useStore(); const deleteDialogRef = ref(null); -const i18nKey = computed(() => props.type.toUpperCase()); +const i18nKey = computed(() => { + return props.translationKey || props.type.toUpperCase(); +}); const deleteEntity = async payload => { if (!payload) return; diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue new file mode 100644 index 000000000..208a94dba --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue @@ -0,0 +1,73 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue new file mode 100644 index 000000000..0745c6546 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue @@ -0,0 +1,87 @@ + + +