diff --git a/Gemfile b/Gemfile index ff3fa3273..ce8fc4dae 100644 --- a/Gemfile +++ b/Gemfile @@ -104,7 +104,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 5d500058d..53632b607 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) @@ -882,8 +882,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 @@ -1110,7 +1110,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/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index bf3a7f221..974fb05e4 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -12,9 +12,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController return handle_mfa_verification if mfa_verification_request? return handle_sso_authentication if sso_authentication_request? - super do |resource| - return handle_mfa_required(resource) if resource&.mfa_enabled? - end + user = find_user_for_authentication + return handle_mfa_required(user) if user&.mfa_enabled? + + # Only proceed with standard authentication if no MFA is required + super end def render_create_success @@ -23,6 +25,17 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController private + def find_user_for_authentication + return nil unless params[:email].present? && params[:password].present? + + normalized_email = params[:email].strip.downcase + user = User.from_email(normalized_email) + return nil unless user&.valid_password?(params[:password]) + return nil unless user.active_for_authentication? + + user + end + def mfa_verification_request? params[:mfa_token].present? end @@ -59,10 +72,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController @resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token]) end - def handle_mfa_required(resource) + def handle_mfa_required(user) render json: { mfa_required: true, - mfa_token: Mfa::TokenService.new(user: resource).generate_token + mfa_token: Mfa::TokenService.new(user: user).generate_token }, status: :partial_content end diff --git a/app/controllers/public/api/v1/inboxes/conversations_controller.rb b/app/controllers/public/api/v1/inboxes/conversations_controller.rb index 4e3b5dca9..242dcde77 100644 --- a/app/controllers/public/api/v1/inboxes/conversations_controller.rb +++ b/app/controllers/public/api/v1/inboxes/conversations_controller.rb @@ -3,7 +3,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status] def index - @conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations + @conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations end def show; end 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/finders/email_channel_finder.rb b/app/finders/email_channel_finder.rb index 41cd8e910..1b6d6f844 100644 --- a/app/finders/email_channel_finder.rb +++ b/app/finders/email_channel_finder.rb @@ -6,19 +6,54 @@ class EmailChannelFinder end def perform - channel = nil - - recipient_mails.each do |email| - normalized_email = normalize_email_with_plus_addressing(email) - channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email) - - break if channel.present? - end - channel + channel_from_primary_recipients || channel_from_bcc_recipients end - def recipient_mails - recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)] - recipient_addresses.flatten.compact + private + + def channel_from_primary_recipients + primary_recipient_emails.each do |email| + channel = channel_from_email(email) + return channel if channel.present? + end + + nil + end + + def channel_from_bcc_recipients + bcc_recipient_emails.each do |email| + channel = channel_from_email(email) + + # Skip if BCC processing is disabled for this account + next if channel && !allow_bcc_processing?(channel.account_id) + + return channel if channel.present? + end + + nil + end + + def primary_recipient_emails + (@email_object.to.to_a + @email_object.cc.to_a + [@email_object['X-Original-To'].try(:value)]).flatten.compact + end + + def bcc_recipient_emails + @email_object.bcc.to_a.flatten.compact + end + + def channel_from_email(email) + normalized_email = normalize_email_with_plus_addressing(email) + Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email) + end + + def bcc_processing_skipped_accounts + config_value = GlobalConfigService.load('SKIP_INCOMING_BCC_PROCESSING', '') + return [] if config_value.blank? + + config_value.split(',').map(&:to_i) + end + + def allow_bcc_processing?(account_id) + bcc_processing_skipped_accounts.exclude?(account_id) end end 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/copilot/ToggleCopilotAssistant.vue b/app/javascript/dashboard/components-next/copilot/ToggleCopilotAssistant.vue index ec21cec9f..ccb36da01 100644 --- a/app/javascript/dashboard/components-next/copilot/ToggleCopilotAssistant.vue +++ b/app/javascript/dashboard/components-next/copilot/ToggleCopilotAssistant.vue @@ -45,7 +45,7 @@ const activeAssistantLabel = computed(() => { /> - + { :class="dropdownPosition" strong > - + { - + { :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" /> - +