diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 1442c5799..2725c55a3 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -46,6 +46,14 @@ class V2::ReportBuilder } end + def short_summary + { + conversations_count: conversations.count, + avg_first_response_time: avg_first_response_time_summary, + avg_resolution_time: avg_resolution_time_summary + } + end + def conversation_metrics if params[:type].equal?(:account) live_conversations diff --git a/app/controllers/api/v1/accounts/notifications_controller.rb b/app/controllers/api/v1/accounts/notifications_controller.rb index 9cea3fbb4..a3d8df27e 100644 --- a/app/controllers/api/v1/accounts/notifications_controller.rb +++ b/app/controllers/api/v1/accounts/notifications_controller.rb @@ -9,7 +9,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro def index @unread_count = notification_finder.unread_count @notifications = notification_finder.perform - @count = @notifications.count + @count = notification_finder.count end def read_all diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index c35a28d7d..0a6c89860 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -14,7 +14,14 @@ module AccessTokenAuthHelper render_unauthorized('Invalid Access Token') && return if @access_token.blank? @resource = @access_token.owner - Current.user = @resource if [User, AgentBot].include?(@resource.class) + Current.user = @resource if allowed_current_user_type?(@resource) + end + + def allowed_current_user_type?(resource) + return true if resource.is_a?(User) + return true if resource.is_a?(AgentBot) + + false end def validate_bot_access_token! diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index e003a62ba..4d3cc56b8 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -1,6 +1,7 @@ class Public::Api::V1::Portals::BaseController < PublicController before_action :show_plain_layout before_action :set_color_scheme + before_action :set_global_config around_action :set_locale after_action :allow_iframe_requests @@ -60,4 +61,8 @@ class Public::Api::V1::Portals::BaseController < PublicController portal render 'public/api/v1/portals/error/404', status: :not_found end + + def set_global_config + @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL') + end end diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 57b750274..d734a346e 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -53,7 +53,7 @@ module Api::V1::InboxesHelper rescue StandardError => e raise StandardError, e.message ensure - ChatwootExceptionTracker.new(e).capture_exception if e.present? + Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present? end def check_smtp_connection(channel_data, smtp) diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 0604eba2f..3d87154fd 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -37,7 +37,7 @@ module Api::V2::Accounts::ReportsHelper business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) } ) - ).summary + ).short_summary end private diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index f3e901d37..31fe6110d 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -519,6 +519,9 @@ export default { }, }, watch: { + teamId() { + this.updateVirtualListProps('teamId', this.teamId); + }, activeTeam() { this.resetAndFetchData(); }, diff --git a/app/javascript/dashboard/i18n/locale/ru/chatlist.json b/app/javascript/dashboard/i18n/locale/ru/chatlist.json index 067e4d38f..fce9f36e1 100644 --- a/app/javascript/dashboard/i18n/locale/ru/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/ru/chatlist.json @@ -126,6 +126,6 @@ "NO_CONTENT": "Содержимое отсутствует", "HIDE_QUOTED_TEXT": "Скрыть цитируемый текст", "SHOW_QUOTED_TEXT": "Показать цитируемый текст", - "MESSAGE_READ": "Читать" + "MESSAGE_READ": "Прочитано" } } diff --git a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationPanelItem.vue b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationPanelItem.vue index 262114816..3a4fa0c86 100644 --- a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationPanelItem.vue +++ b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationPanelItem.vue @@ -56,7 +56,7 @@ - {{ dynamicTime(notificationItem.created_at) }} + {{ dynamicTime(notificationItem.last_activity_at) }} diff --git a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationTable.vue b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationTable.vue index 650a2b942..3cc4dde62 100644 --- a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationTable.vue +++ b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationTable.vue @@ -58,7 +58,7 @@
- {{ dynamicTime(notificationItem.created_at) }} + {{ dynamicTime(notificationItem.last_activity_at) }}
diff --git a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationsView.vue b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationsView.vue index 39a1c6b58..058305a7a 100644 --- a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationsView.vue +++ b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationsView.vue @@ -11,6 +11,7 @@ diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index c577b1908..22be5f036 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -1,6 +1,8 @@ class ApplicationJob < ActiveJob::Base # https://api.rubyonrails.org/v5.2.1/classes/ActiveJob/Exceptions/ClassMethods.html - discard_on ActiveJob::DeserializationError do |_job, error| - Rails.logger.error("Skipping job because of ActiveJob::DeserializationError (#{error.message})") + discard_on ActiveJob::DeserializationError do |job, error| + Rails.logger.info("Skipping #{job.class} with #{ + job.instance_variable_get(:@serialized_arguments) + } because of ActiveJob::DeserializationError (#{error.message})") end end diff --git a/app/jobs/data_import_job.rb b/app/jobs/data_import_job.rb index 0a973c8bd..9703d2e50 100644 --- a/app/jobs/data_import_job.rb +++ b/app/jobs/data_import_job.rb @@ -8,8 +8,12 @@ class DataImportJob < ApplicationJob def perform(data_import) @data_import = data_import @contact_manager = DataImport::ContactManager.new(@data_import.account) - process_import_file - send_import_notification_to_admin + begin + process_import_file + send_import_notification_to_admin + rescue CSV::MalformedCSVError => e + handle_csv_error(e) + end end private @@ -83,7 +87,16 @@ class DataImportJob < ApplicationJob end end + def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument + @data_import.update!(status: :failed) + send_import_failed_notification_to_admin + end + def send_import_notification_to_admin AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later end + + def send_import_failed_notification_to_admin + AdministratorNotifications::ChannelNotificationsMailer.with(account: @data_import.account).contact_import_failed.deliver_later + end end diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index 8d3582440..c59ae2787 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -1,17 +1,21 @@ require 'net/imap' -class Inboxes::FetchImapEmailsJob < ApplicationJob +class Inboxes::FetchImapEmailsJob < MutexApplicationJob queue_as :scheduled_jobs def perform(channel) return unless should_fetch_email?(channel) - process_email_for_channel(channel) + with_lock(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) do + process_email_for_channel(channel) + end rescue *ExceptionList::IMAP_EXCEPTIONS => e Rails.logger.error e channel.authorization_error! rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e Rails.logger.error e + rescue LockAcquisitionError + Rails.logger.error "Lock failed for #{channel.inbox.id}" rescue StandardError => e ChatwootExceptionTracker.new(e, account: channel.account).capture_exception end @@ -23,7 +27,6 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob end def process_email_for_channel(channel) - # fetching email for microsoft provider if channel.microsoft? fetch_mail_for_ms_provider(channel) else @@ -34,32 +37,66 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob end def fetch_mail_for_channel(channel) - imap_inbox = authenticated_imap_inbox(channel, channel.imap_password, 'PLAIN') - last_email_time = DateTime.parse(Net::IMAP.format_datetime(last_email_time(channel))) + imap_client = build_imap_client(channel, channel.imap_password, 'PLAIN') - received_mails(imap_inbox).each do |message_id| - inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822'] - - mail_info_logger(channel, inbound_mail, message_id) - - next if email_already_present?(channel, inbound_mail, last_email_time) - - process_mail(inbound_mail, channel) + message_ids_with_seq = fetch_message_ids_with_sequence(imap_client, channel) + message_ids_with_seq.each do |message_id_with_seq| + process_message_id(channel, imap_client, message_id_with_seq) end end - def email_already_present?(channel, inbound_mail, _last_email_time) - channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present? + def process_message_id(channel, imap_client, message_id_with_seq) + seq_no, message_id = message_id_with_seq + + return if email_already_present?(channel, message_id) + + # Fetch the original mail content using the sequence no + mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822'] + + if mail_str.blank? + Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>." + return + end + + inbound_mail = build_mail_from_string(mail_str) + mail_info_logger(channel, inbound_mail, seq_no) + process_mail(inbound_mail, channel) end - def received_mails(imap_inbox) - imap_inbox.search(['BEFORE', tomorrow, 'SINCE', yesterday]) + # Sends a FETCH command to retrieve data associated with a message in the mailbox. + # You can send batches of message sequence number in `.fetch` method. + def fetch_message_ids_with_sequence(imap_client, channel) + seq_nums = fetch_available_mail_sequence_numbers(imap_client) + + Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}." + + message_ids_with_seq = [] + seq_nums.each_slice(10).each do |batch| + # Fetch only message-id only without mail body or contents. + batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)]') + + # .fetch returns an array of Net::IMAP::FetchData or nil + # (instead of an empty array) if there is no matching message. + # Check + if batch_message_ids.blank? + Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}." + next + end + + batch_message_ids.each do |data| + message_id = build_mail_from_string(data.attr['BODY[HEADER.FIELDS (MESSAGE-ID)]']).message_id + message_ids_with_seq.push([data.seqno, message_id]) + end + end + + message_ids_with_seq end - def processed_email?(current_email, last_email_time) - return current_email.date < last_email_time if current_email.date.present? - - false + # Sends a SEARCH command to search the mailbox for messages that were + # created between yesterday and today and returns message sequence numbers. + # Return + def fetch_available_mail_sequence_numbers(imap_client) + imap_client.search(['BEFORE', tomorrow, 'SINCE', yesterday]) end def fetch_mail_for_ms_provider(channel) @@ -69,16 +106,15 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob return unless access_token - imap_inbox = authenticated_imap_inbox(channel, access_token, 'XOAUTH2') - - process_mails(imap_inbox, channel) + imap_client = build_imap_client(channel, access_token, 'XOAUTH2') + process_mails(imap_client, channel) end - def process_mails(imap_inbox, channel) - received_mails(imap_inbox).each do |message_id| - inbound_mail = Mail.read_from_string imap_inbox.fetch(message_id, 'RFC822')[0].attr['RFC822'] + def process_mails(imap_client, channel) + fetch_available_mail_sequence_numbers(imap_client).each do |seq_no| + inbound_mail = Mail.read_from_string imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822'] - mail_info_logger(channel, inbound_mail, message_id) + mail_info_logger(channel, inbound_mail, seq_no) next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present? @@ -86,38 +122,26 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob end end - def mail_info_logger(channel, inbound_mail, message_id) + def mail_info_logger(channel, inbound_mail, uid) return if Rails.env.test? Rails.logger.info(" - #{channel.provider} Email id: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}, message_id: #{message_id}") + #{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{uid}") end - def authenticated_imap_inbox(channel, access_token, auth_method) + def build_imap_client(channel, access_token, auth_method) imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true) imap.authenticate(auth_method, channel.imap_login, access_token) imap.select('INBOX') imap end - def last_email_time(channel) - # we are only checking for emails in last 2 day - last_email_incoming_message = channel.inbox.messages.incoming.where('messages.created_at >= ?', 2.days.ago).last - if last_email_incoming_message.present? - time = last_email_incoming_message.content_attributes['email']['date'] - time ||= last_email_incoming_message.created_at.to_s - end - time ||= 1.hour.ago.to_s - - DateTime.parse(time) + def email_already_present?(channel, message_id) + channel.inbox.messages.find_by(source_id: message_id).present? end - def yesterday - (Time.zone.today - 1).strftime('%d-%b-%Y') - end - - def tomorrow - (Time.zone.today + 1).strftime('%d-%b-%Y') + def build_mail_from_string(raw_email_content) + Mail.read_from_string(raw_email_content) end def process_mail(inbound_mail, channel) @@ -132,4 +156,12 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob def valid_access_token(channel) Microsoft::RefreshOauthTokenService.new(channel: channel).access_token end + + def yesterday + (Time.zone.today - 1).strftime('%d-%b-%Y') + end + + def tomorrow + (Time.zone.today + 1).strftime('%d-%b-%Y') + end end diff --git a/app/jobs/mutex_application_job.rb b/app/jobs/mutex_application_job.rb index d6d20b1e2..98e6bf9cd 100644 --- a/app/jobs/mutex_application_job.rb +++ b/app/jobs/mutex_application_job.rb @@ -18,17 +18,15 @@ class MutexApplicationJob < ApplicationJob lock_key = format(key_format, *args) lock_manager = Redis::LockManager.new - if lock_manager.locked?(lock_key) - Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}" - raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}" - end - begin - lock_manager.lock(lock_key) - Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}" - yield + if lock_manager.lock(lock_key) + Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}" + yield + else + Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}" + raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}" + end ensure - # Ensure that the lock is released even if there's an error in processing lock_manager.unlock(lock_key) end end diff --git a/app/jobs/notification/remove_old_notification_job.rb b/app/jobs/notification/remove_old_notification_job.rb new file mode 100644 index 000000000..770461744 --- /dev/null +++ b/app/jobs/notification/remove_old_notification_job.rb @@ -0,0 +1,8 @@ +class Notification::RemoveOldNotificationJob < ApplicationJob + queue_as :low + + def perform + Notification.where('created_at < ?', 1.month.ago) + .find_each(batch_size: 1000, &:delete) + end +end diff --git a/app/jobs/trigger_scheduled_items_job.rb b/app/jobs/trigger_scheduled_items_job.rb index 9fc7a6491..82634e4d5 100644 --- a/app/jobs/trigger_scheduled_items_job.rb +++ b/app/jobs/trigger_scheduled_items_job.rb @@ -19,5 +19,8 @@ class TriggerScheduledItemsJob < ApplicationJob # Job to sync whatsapp templates Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later + + # Job to clear notifications which are older than 1 month + Notification::RemoveOldNotificationJob.perform_later end end diff --git a/app/mailers/administrator_notifications/channel_notifications_mailer.rb b/app/mailers/administrator_notifications/channel_notifications_mailer.rb index b25a6d66b..8c52a9bd3 100644 --- a/app/mailers/administrator_notifications/channel_notifications_mailer.rb +++ b/app/mailers/administrator_notifications/channel_notifications_mailer.rb @@ -51,6 +51,15 @@ class AdministratorNotifications::ChannelNotificationsMailer < ApplicationMailer send_mail_with_liquid(to: admin_emails, subject: subject) and return end + def contact_import_failed + return unless smtp_config_set_or_development? + + subject = 'Contact Import Failed' + + @meta = {} + send_mail_with_liquid(to: admin_emails, subject: subject) and return + end + def contact_export_complete(file_url) return unless smtp_config_set_or_development? diff --git a/app/models/channel/telegram.rb b/app/models/channel/telegram.rb index 365ca59c8..f6b97f2d2 100644 --- a/app/models/channel/telegram.rb +++ b/app/models/channel/telegram.rb @@ -149,24 +149,32 @@ class Channel::Telegram < ApplicationRecord }) end - def convert_markdown_to_telegram(text) - ## supported characters : https://core.telegram.org/bots/api#markdown-style - ## To implement MarkdownV2, we will need to do a lot of escaping + def convert_markdown_to_telegram_html(text) + # ref: https://core.telegram.org/bots/api#html-style - # Convert bold - double asterisks to single asterisk in Telegram - # Chatwoot uses double asterisks for bold, while telegram used single asterisk - text.gsub!(/\*\*(.*?)\*\*/, '*\1*') - text + # escape html tags in text. We are subbing \n to
since commonmark will strip exta '\n' + text = CGI.escapeHTML(text.gsub("\n", '
')) + + # convert markdown to html + html = CommonMarker.render_html(text).strip + + # remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote + stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote], + attributes: %w[href]) + + # converted escaped br tags to \n + stripped_html.gsub('<br>', "\n") end def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil) - text_to_md = convert_markdown_to_telegram(text) + text_payload = convert_markdown_to_telegram_html(text) + HTTParty.post("#{telegram_api_url}/sendMessage", body: { chat_id: chat_id, - text: text_to_md, + text: text_payload, reply_markup: reply_markup, - parse_mode: 'Markdown', + parse_mode: 'HTML', reply_to_message_id: reply_to_message_id }) end diff --git a/app/models/dashboard_app.rb b/app/models/dashboard_app.rb index 7316dd344..e8a7edd4c 100644 --- a/app/models/dashboard_app.rb +++ b/app/models/dashboard_app.rb @@ -33,9 +33,12 @@ class DashboardApp < ApplicationRecord 'required' => %w[url type], 'properties' => { 'type' => { 'enum': ['frame'] }, - 'url' => { :type => 'string', 'format' => 'uri' } + 'url' => { '$ref' => '#/definitions/saneUrl' } } }, + 'definitions' => { + 'saneUrl' => { 'format' => 'uri', 'pattern' => '^https?://' } + }, 'additionalProperties' => false, 'minItems' => 1 } diff --git a/app/views/api/v1/accounts/notifications/index.json.jbuilder b/app/views/api/v1/accounts/notifications/index.json.jbuilder index e04ce22cf..b46dcb9ec 100644 --- a/app/views/api/v1/accounts/notifications/index.json.jbuilder +++ b/app/views/api/v1/accounts/notifications/index.json.jbuilder @@ -19,6 +19,7 @@ json.data do json.secondary_actor notification.secondary_actor&.push_event_data json.user notification.user.push_event_data json.created_at notification.created_at.to_i + json.last_activity_at notification.last_activity_at.to_i end end end diff --git a/app/views/icons/_chatwoot-logo.html.erb b/app/views/icons/_chatwoot-logo.html.erb deleted file mode 100644 index 5377a0a02..000000000 --- a/app/views/icons/_chatwoot-logo.html.erb +++ /dev/null @@ -1,11 +0,0 @@ - - - woot-log - Created with Sketch. - - \ No newline at end of file diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_failed.liquid b/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_failed.liquid new file mode 100644 index 000000000..835cff258 --- /dev/null +++ b/app/views/mailers/administrator_notifications/channel_notifications_mailer/contact_import_failed.liquid @@ -0,0 +1,3 @@ +

Hello,

+ +

Your contact import has failed. It appears that the CSV file you uploaded may not be valid. We kindly request that you review the file and ensure it complies with the required format.

diff --git a/app/views/public/api/v1/portals/_footer.html.erb b/app/views/public/api/v1/portals/_footer.html.erb index 162f0d9b6..8de87b8f2 100644 --- a/app/views/public/api/v1/portals/_footer.html.erb +++ b/app/views/public/api/v1/portals/_footer.html.erb @@ -2,9 +2,17 @@