From 24e6a92297aaa24959c841f37de408d4edea0455 Mon Sep 17 00:00:00 2001 From: "Aswin Dev P.S" Date: Thu, 18 Nov 2021 22:22:27 -0800 Subject: [PATCH] feat: IMAP Email Channel (#3298) This change allows the user to configure both IMAP and SMTP for an email inbox. IMAP enables the user to see emails in Chatwoot. And user can use SMTP to reply to an email conversation. Users can use the default settings to send and receive emails for email inboxes if both IMAP and SMTP are disabled. Fixes #2520 --- .../api/v1/accounts/inboxes_controller.rb | 4 +- app/helpers/api/v1/inboxes_helper.rb | 33 ++++ .../dashboard/i18n/locale/en/inboxMgmt.json | 59 +++++++ .../dashboard/settings/inbox/ImapSettings.vue | 163 ++++++++++++++++++ .../dashboard/settings/inbox/Settings.vue | 6 + .../dashboard/settings/inbox/SmtpSettings.vue | 163 ++++++++++++++++++ .../dashboard/store/modules/inboxes.js | 48 ++++++ .../modules/specs/inboxes/actions.spec.js | 60 +++++++ .../inboxes/fetch_imap_email_inboxes_job.rb | 9 + app/jobs/inboxes/fetch_imap_emails_job.rb | 24 +++ app/mailboxes/imap/imap_mailbox.rb | 79 +++++++++ app/mailboxes/mailbox_helper.rb | 15 ++ app/mailboxes/support_mailbox.rb | 15 -- app/mailers/conversation_reply_mailer.rb | 34 +--- .../conversation_reply_mailer_helper.rb | 51 ++++++ app/models/channel/email.rb | 30 +++- app/views/api/v1/models/_inbox.json.jbuilder | 16 ++ config/schedule.yml | 15 +- ...3_add_imap_smtp_config_to_channel_email.rb | 24 +++ db/schema.rb | 15 ++ .../v1/accounts/inboxes_controller_spec.rb | 51 ++++++ .../fetch_imap_email_inboxes_job_spec.rb | 27 +++ .../inboxes/fetch_imap_emails_job_spec.rb | 37 ++++ spec/mailboxes/imap/imap_mailbox_spec.rb | 93 ++++++++++ .../mailers/conversation_reply_mailer_spec.rb | 26 +++ 25 files changed, 1040 insertions(+), 57 deletions(-) create mode 100644 app/helpers/api/v1/inboxes_helper.rb create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/ImapSettings.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/SmtpSettings.vue create mode 100644 app/jobs/inboxes/fetch_imap_email_inboxes_job.rb create mode 100644 app/jobs/inboxes/fetch_imap_emails_job.rb create mode 100644 app/mailboxes/imap/imap_mailbox.rb create mode 100644 app/mailers/conversation_reply_mailer_helper.rb create mode 100644 db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb create mode 100644 spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb create mode 100644 spec/jobs/inboxes/fetch_imap_emails_job_spec.rb create mode 100644 spec/mailboxes/imap/imap_mailbox_spec.rb diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index d09565da3..0c80d9983 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController + include Api::V1::InboxesHelper before_action :fetch_inbox, except: [:index, :create] before_action :fetch_agent_bot, only: [:set_agent_bot] # we are already handling the authorization in fetch inbox @@ -41,12 +42,13 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController def update @inbox.update(permitted_params.except(:channel)) @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] - channel_attributes = get_channel_attributes(@inbox.channel_type) # Inbox update doesn't necessarily need channel attributes return if permitted_params(channel_attributes)[:channel].blank? + validate_email_channel(channel_attributes) if @inbox.inbox_type == 'Email' + @inbox.channel.update!(permitted_params(channel_attributes)[:channel]) update_channel_feature_flags end diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb new file mode 100644 index 000000000..08f000cc6 --- /dev/null +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -0,0 +1,33 @@ +module Api::V1::InboxesHelper + def validate_email_channel(attributes) + channel_data = permitted_params(attributes)[:channel] + + validate_imap(channel_data) + validate_smtp(channel_data) + end + + private + + def validate_imap(channel_data) + return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled] + + Mail.defaults do + retriever_method :imap, { address: channel_data[:imap_address], + port: channel_data[:imap_port], + user_name: channel_data[:imap_email], + password: channel_data[:imap_password], + enable_ssl: channel_data[:imap_enable_ssl] } + end + + Mail.connection do # rubocop:disable:block + end + end + + def validate_smtp(channel_data) + return unless channel_data.key?('smtp_enabled') && channel_data[:smtp_enabled] + + smtp = Net::SMTP.start(channel_data[:smtp_address], channel_data[:smtp_port], channel_data[:smtp_domain], channel_data[:smtp_email], + channel_data[:smtp_password], :login) + smtp.finish unless smtp&.nil? + end +end diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index b05a730e6..774c8160d 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -401,6 +401,65 @@ "VALIDATION_ERROR": "Starting time should be before closing time.", "CHOOSE": "Choose" } + }, + "IMAP": { + "TITLE": "IMAP", + "SUBTITLE": "Set your IMAP details", + "UPDATE": "Update IMAP settings", + "TOGGLE_AVAILABILITY": "Enable IMAP configuration for this inbox", + "TOGGLE_HELP": "Enabling IMAP will help the user to recieve email", + "EDIT": { + "SUCCESS_MESSAGE": "IMAP settings updated successfully", + "ERROR_MESSAGE": "Unable to update IMAP settings" + }, + "ADDRESS": { + "LABEL": "Address", + "PLACE_HOLDER": "Address (Eg: imap.gmail.com)" + }, + "PORT": { + "LABEL": "Port", + "PLACE_HOLDER": "Port" + }, + "EMAIL": { + "LABEL": "Email", + "PLACE_HOLDER": "Email" + }, + "PASSWORD": { + "LABEL": "Password", + "PLACE_HOLDER": "Password" + }, + "ENABLE_SSL": "Enable SSL" + }, + "SMTP": { + "TITLE": "SMTP", + "SUBTITLE": "Set your SMTP details", + "UPDATE": "Update SMTP settings", + "TOGGLE_AVAILABILITY": "Enable SMTP configuration for this inbox", + "TOGGLE_HELP": "Enabling SMTP will help the user to send email", + "EDIT": { + "SUCCESS_MESSAGE": "SMTP settings updated successfully", + "ERROR_MESSAGE": "Unable to update SMTP settings" + }, + "ADDRESS": { + "LABEL": "Address", + "PLACE_HOLDER": "Address (Eg: smtp.gmail.com)" + }, + "PORT": { + "LABEL": "Port", + "PLACE_HOLDER": "Port" + }, + "EMAIL": { + "LABEL": "Email", + "PLACE_HOLDER": "Email" + }, + "PASSWORD": { + "LABEL": "Password", + "PLACE_HOLDER": "Password" + }, + "DOMAIN": { + "LABEL": "Domain", + "PLACE_HOLDER": "Domain" + } } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ImapSettings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ImapSettings.vue new file mode 100644 index 000000000..801e6fa98 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ImapSettings.vue @@ -0,0 +1,163 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 9d63e5781..7c8db94d5 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -353,6 +353,8 @@ + +
@@ -378,6 +380,8 @@ import FacebookReauthorize from './facebook/Reauthorize'; import PreChatFormSettings from './PreChatForm/Settings'; import WeeklyAvailability from './components/WeeklyAvailability'; import GreetingsEditor from 'shared/components/GreetingsEditor'; +import ImapSettings from './ImapSettings'; +import SmtpSettings from './SmtpSettings'; export default { components: { @@ -387,6 +391,8 @@ export default { PreChatFormSettings, WeeklyAvailability, GreetingsEditor, + ImapSettings, + SmtpSettings, }, mixins: [alertMixin, configMixin, inboxMixin], data() { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/SmtpSettings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/SmtpSettings.vue new file mode 100644 index 000000000..009e3cbb3 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/SmtpSettings.vue @@ -0,0 +1,163 @@ + + + + diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 2bb554ba3..83102ba97 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -35,6 +35,8 @@ export const state = { isUpdating: false, isUpdatingAutoAssignment: false, isDeleting: false, + isUpdatingIMAP: false, + isUpdatingSMTP: false, }, }; @@ -164,6 +166,52 @@ export const actions = { throw new Error(error); } }, + updateInboxIMAP: async ( + { commit }, + { id, formData = true, ...inboxParams } + ) => { + commit(types.default.SET_INBOXES_UI_FLAG, { + isUpdatingIMAP: true, + }); + try { + const response = await InboxesAPI.update( + id, + formData ? buildInboxData(inboxParams) : inboxParams + ); + commit(types.default.EDIT_INBOXES, response.data); + commit(types.default.SET_INBOXES_UI_FLAG, { + isUpdatingIMAP: false, + }); + } catch (error) { + commit(types.default.SET_INBOXES_UI_FLAG, { + isUpdatingIMAP: false, + }); + throw new Error(error); + } + }, + updateInboxSMTP: async ( + { commit }, + { id, formData = true, ...inboxParams } + ) => { + commit(types.default.SET_INBOXES_UI_FLAG, { + isUpdatingSMTP: true, + }); + try { + const response = await InboxesAPI.update( + id, + formData ? buildInboxData(inboxParams) : inboxParams + ); + commit(types.default.EDIT_INBOXES, response.data); + commit(types.default.SET_INBOXES_UI_FLAG, { + isUpdatingSMTP: false, + }); + } catch (error) { + commit(types.default.SET_INBOXES_UI_FLAG, { + isUpdatingSMTP: false, + }); + throw new Error(error); + } + }, delete: async ({ commit }, inboxId) => { commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: true }); try { diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js index da3424267..80cf92f25 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js @@ -107,6 +107,66 @@ describe('#actions', () => { }); }); + describe('#updateInboxIMAP', () => { + it('sends correct actions if API is success', async () => { + const updatedInbox = inboxList[0]; + + axios.patch.mockResolvedValue({ data: updatedInbox }); + await actions.updateInboxIMAP( + { commit }, + { id: updatedInbox.id, inbox: { channel: { imap_enabled: true } } } + ); + expect(commit.mock.calls).toEqual([ + [types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: true }], + [types.default.EDIT_INBOXES, updatedInbox], + [types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.patch.mockRejectedValue({ message: 'Incorrect header' }); + await expect( + actions.updateInboxIMAP( + { commit }, + { id: inboxList[0].id, inbox: { channel: { imap_enabled: true } } } + ) + ).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: true }], + [types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: false }], + ]); + }); + }); + + describe('#updateInboxSMTP', () => { + it('sends correct actions if API is success', async () => { + const updatedInbox = inboxList[0]; + + axios.patch.mockResolvedValue({ data: updatedInbox }); + await actions.updateInboxSMTP( + { commit }, + { id: updatedInbox.id, inbox: { channel: { smtp_enabled: true } } } + ); + expect(commit.mock.calls).toEqual([ + [types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: true }], + [types.default.EDIT_INBOXES, updatedInbox], + [types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.patch.mockRejectedValue({ message: 'Incorrect header' }); + await expect( + actions.updateInboxSMTP( + { commit }, + { id: inboxList[0].id, inbox: { channel: { smtp_enabled: true } } } + ) + ).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: true }], + [types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: false }], + ]); + }); + }); + describe('#delete', () => { it('sends correct actions if API is success', async () => { axios.delete.mockResolvedValue({ data: inboxList[0] }); diff --git a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb new file mode 100644 index 000000000..ec0ca7d21 --- /dev/null +++ b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb @@ -0,0 +1,9 @@ +class Inboxes::FetchImapEmailInboxesJob < ApplicationJob + queue_as :low + + def perform + Inbox.where(channel_type: 'Channel::Email').all.each do |inbox| + Inboxes::FetchImapEmailsJob.perform_later(inbox.channel) if inbox.channel.imap_enabled + end + end +end diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb new file mode 100644 index 000000000..fa18e6599 --- /dev/null +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -0,0 +1,24 @@ +class Inboxes::FetchImapEmailsJob < ApplicationJob + queue_as :low + + def perform(channel) + Mail.defaults do + retriever_method :imap, address: channel.imap_address, + port: channel.imap_port, + user_name: channel.imap_email, + password: channel.imap_password, + enable_ssl: channel.imap_enable_ssl + end + + new_mails = false + + Mail.find(what: :last, count: 10, order: :desc).each do |inbound_mail| + if inbound_mail.date.utc >= channel.imap_inbox_synced_at + Imap::ImapMailbox.new.process(inbound_mail, channel) + new_mails = true + end + end + + Channel::Email.update(channel.id, imap_inbox_synced_at: Time.now.utc) if new_mails + end +end diff --git a/app/mailboxes/imap/imap_mailbox.rb b/app/mailboxes/imap/imap_mailbox.rb new file mode 100644 index 000000000..a9ed0a7c9 --- /dev/null +++ b/app/mailboxes/imap/imap_mailbox.rb @@ -0,0 +1,79 @@ +class Imap::ImapMailbox + include MailboxHelper + attr_accessor :channel, :account, :inbox, :conversation, :processed_mail + + def process(mail, channel) + @inbound_mail = mail + @channel = channel + load_account + load_inbox + decorate_mail + + # prevent loop from chatwoot notification emails + return if notification_email_from_chatwoot? + + ActiveRecord::Base.transaction do + find_or_create_contact + find_or_create_conversation + create_message + add_attachments_to_message + end + end + + private + + def load_account + @account = @channel.account + end + + def load_inbox + @inbox = @channel.inbox + end + + def decorate_mail + @processed_mail = MailPresenter.new(@inbound_mail, @account) + end + + def find_conversation_by_in_reply_to + return if in_reply_to.blank? + + message = @inbox.messages.find_by(source_id: in_reply_to) + if message.nil? + @inbox.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first + else + @inbox.conversations.find(message.conversation_id) + end + end + + def in_reply_to + @inbound_mail.in_reply_to + end + + def find_or_create_conversation + @conversation = find_conversation_by_in_reply_to || ::Conversation.create!({ account_id: @account.id, + inbox_id: @inbox.id, + contact_id: @contact.id, + contact_inbox_id: @contact_inbox.id, + additional_attributes: { + source: 'email', + in_reply_to: in_reply_to, + mail_subject: @processed_mail.subject, + initiated_at: { + timestamp: Time.now.utc + } + } }) + end + + def find_or_create_contact + @contact = @inbox.contacts.find_by(email: @processed_mail.original_sender) + if @contact.present? + @contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact) + else + create_contact + end + end + + def identify_contact_name + processed_mail.sender_name || processed_mail.from.first.split('@').first + end +end diff --git a/app/mailboxes/mailbox_helper.rb b/app/mailboxes/mailbox_helper.rb index 15379bb86..7cdfebe30 100644 --- a/app/mailboxes/mailbox_helper.rb +++ b/app/mailboxes/mailbox_helper.rb @@ -29,6 +29,21 @@ module MailboxHelper @message.save! end + def create_contact + @contact_inbox = ::ContactBuilder.new( + source_id: "email:#{processed_mail.message_id}", + inbox: @inbox, + contact_attributes: { + name: identify_contact_name, + email: processed_mail.original_sender, + additional_attributes: { + source_id: "email:#{processed_mail.message_id}" + } + } + ).perform + @contact = @contact_inbox.contact + end + def notification_email_from_chatwoot? # notification emails are send via mailer sender email address. so it should match @processed_mail.original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot ')).address diff --git a/app/mailboxes/support_mailbox.rb b/app/mailboxes/support_mailbox.rb index 9de2f79c2..d215310e0 100644 --- a/app/mailboxes/support_mailbox.rb +++ b/app/mailboxes/support_mailbox.rb @@ -78,21 +78,6 @@ class SupportMailbox < ApplicationMailbox end end - def create_contact - @contact_inbox = ::ContactBuilder.new( - source_id: "email:#{processed_mail.message_id}", - inbox: @inbox, - contact_attributes: { - name: identify_contact_name, - email: @processed_mail.original_sender, - additional_attributes: { - source_id: "email:#{processed_mail.message_id}" - } - } - ).perform - @contact = @contact_inbox.contact - end - def identify_contact_name processed_mail.sender_name || processed_mail.from.first.split('@').first end diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index 5d4770bd7..333eb01fe 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -1,4 +1,5 @@ class ConversationReplyMailer < ApplicationMailer + include ConversationReplyMailerHelper default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot ') layout :choose_layout @@ -12,16 +13,7 @@ class ConversationReplyMailer < ApplicationMailer new_messages = @conversation.messages.chat.where('id >= ?', last_queued_id) @messages = recap_messages + new_messages @messages = @messages.select(&:email_reply_summarizable?) - mail({ - to: @contact&.email, - from: from_email_with_name, - reply_to: reply_email, - subject: mail_subject, - message_id: custom_message_id, - in_reply_to: in_reply_to_email, - cc: cc_bcc_emails[0], - bcc: cc_bcc_emails[1] - }) + prepare_mail(true) end def reply_without_summary(conversation, last_queued_id) @@ -34,14 +26,7 @@ class ConversationReplyMailer < ApplicationMailer @messages = @messages.reject { |m| m.template? && !m.input_csat? } return false if @messages.count.zero? - mail({ - to: @contact&.email, - from: from_email_with_name, - reply_to: reply_email, - subject: mail_subject, - message_id: custom_message_id, - in_reply_to: in_reply_to_email - }) + prepare_mail(false) end def email_reply(message) @@ -49,17 +34,7 @@ class ConversationReplyMailer < ApplicationMailer init_conversation_attributes(message.conversation) @message = message - - reply_mail_object = mail({ - to: @contact&.email, - from: from_email_with_name, - reply_to: reply_email, - subject: mail_subject, - message_id: custom_message_id, - in_reply_to: in_reply_to_email, - cc: cc_bcc_emails[0], - bcc: cc_bcc_emails[1] - }) + reply_mail_object = prepare_mail(true) message.update(source_id: reply_mail_object.message_id) end @@ -86,6 +61,7 @@ class ConversationReplyMailer < ApplicationMailer @contact = @conversation.contact @agent = @conversation.assignee @inbox = @conversation.inbox + @channel = @inbox.channel end def should_use_conversation_email_address? diff --git a/app/mailers/conversation_reply_mailer_helper.rb b/app/mailers/conversation_reply_mailer_helper.rb new file mode 100644 index 000000000..a9feb0c72 --- /dev/null +++ b/app/mailers/conversation_reply_mailer_helper.rb @@ -0,0 +1,51 @@ +module ConversationReplyMailerHelper + def prepare_mail(cc_bcc_enabled) + @options = { + to: @contact&.email, + from: email_from, + reply_to: email_reply_to, + subject: mail_subject, + message_id: custom_message_id, + in_reply_to: in_reply_to_email + } + + if cc_bcc_enabled + @options[:cc] = cc_bcc_emails[0] + @options[:bcc] = cc_bcc_emails[1] + end + + set_delivery_method + mail(@options) + end + + private + + def set_delivery_method + return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled + + smtp_settings = { + address: @channel.smtp_address, + port: @channel.smtp_port, + user_name: @channel.smtp_email, + password: @channel.smtp_password, + domain: @channel.smtp_domain, + enable_starttls_auto: @channel.smtp_enable_starttls_auto, + authentication: @channel.smtp_authentication + } + + @options[:delivery_method] = :smtp + @options[:delivery_method_options] = smtp_settings + end + + def email_smtp_enabled + @inbox.inbox_type == 'Email' && @channel.imap_enabled + end + + def email_from + email_smtp_enabled ? @channel.smtp_email : from_email_with_name + end + + def email_reply_to + email_smtp_enabled ? @channel.smtp_email : reply_email + end +end diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb index fe711b01e..092a9a101 100644 --- a/app/models/channel/email.rb +++ b/app/models/channel/email.rb @@ -2,12 +2,27 @@ # # Table name: channel_email # -# id :bigint not null, primary key -# email :string not null -# forward_to_email :string not null -# created_at :datetime not null -# updated_at :datetime not null -# account_id :integer not null +# id :bigint not null, primary key +# email :string not null +# forward_to_email :string not null +# imap_address :string default("") +# imap_email :string default("") +# imap_enable_ssl :boolean default(TRUE) +# imap_enabled :boolean default(FALSE) +# imap_inbox_synced_at :datetime +# imap_password :string default("") +# imap_port :integer default(0) +# smtp_address :string default("") +# smtp_authentication :string default("login") +# smtp_domain :string default("") +# smtp_email :string default("") +# smtp_enable_starttls_auto :boolean default(TRUE) +# smtp_enabled :boolean default(FALSE) +# smtp_password :string default("") +# smtp_port :integer default(0) +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null # # Indexes # @@ -19,7 +34,8 @@ class Channel::Email < ApplicationRecord include Channelable self.table_name = 'channel_email' - EDITABLE_ATTRS = [:email].freeze + EDITABLE_ATTRS = [:email, :imap_enabled, :imap_email, :imap_password, :imap_address, :imap_port, :imap_enable_ssl, :imap_inbox_synced_at, + :smtp_enabled, :smtp_email, :smtp_password, :smtp_address, :smtp_port, :smtp_domain].freeze validates :email, uniqueness: true validates :forward_to_email, uniqueness: true diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index e27ebcac4..e4bdbcc66 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -45,6 +45,22 @@ json.medium resource.channel.try(:medium) if resource.twilio? json.forward_to_email resource.channel.try(:forward_to_email) json.email resource.channel.try(:email) if resource.email? +## IMAP +json.imap_email resource.channel.try(:imap_email) if resource.email? +json.imap_password resource.channel.try(:imap_password) if resource.email? +json.imap_address resource.channel.try(:imap_address) if resource.email? +json.imap_port resource.channel.try(:imap_port) if resource.email? +json.imap_enabled resource.channel.try(:imap_enabled) if resource.email? +json.imap_enable_ssl resource.channel.try(:imap_enable_ssl) if resource.email? + +## SMTP +json.smtp_email resource.channel.try(:smtp_email) if resource.email? +json.smtp_password resource.channel.try(:smtp_password) if resource.email? +json.smtp_address resource.channel.try(:smtp_address) if resource.email? +json.smtp_port resource.channel.try(:smtp_port) if resource.email? +json.smtp_enabled resource.channel.try(:smtp_enabled) if resource.email? +json.smtp_domain resource.channel.try(:smtp_domain) if resource.email? + ## API Channel Attributes json.webhook_url resource.channel.try(:webhook_url) if resource.api? json.inbox_identifier resource.channel.try(:identifier) if resource.api? diff --git a/config/schedule.yml b/config/schedule.yml index 4a01893a9..971ee85fd 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -3,13 +3,18 @@ # executed At 12:00 on every day-of-month. internal_check_new_versions_job: - cron: "0 12 */1 * *" - class: "Internal::CheckNewVersionsJob" + cron: '0 12 */1 * *' + class: 'Internal::CheckNewVersionsJob' queue: scheduled_jobs # executed At every 5th minute.. trigger_scheduled_items_job: - cron: "*/5 * * * *" - class: "TriggerScheduledItemsJob" + cron: '*/5 * * * *' + class: 'TriggerScheduledItemsJob' + queue: scheduled_jobs + +# executed At every minute. +trigger_scheduled_items_job: + cron: '*/1 * * * *' + class: 'Inboxes::FetchImapEmailInboxesJob' queue: scheduled_jobs - \ No newline at end of file diff --git a/db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb b/db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb new file mode 100644 index 000000000..7bd7aca50 --- /dev/null +++ b/db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb @@ -0,0 +1,24 @@ +class AddImapSmtpConfigToChannelEmail < ActiveRecord::Migration[6.1] + def change + change_table :channel_email, bulk: true do |t| + # IMAP + t.boolean :imap_enabled, default: false + t.string :imap_address, default: '' + t.integer :imap_port, default: 0 + t.string :imap_email, default: '' + t.string :imap_password, default: '' + t.boolean :imap_enable_ssl, default: true + t.datetime :imap_inbox_synced_at + + # SMTP + t.boolean :smtp_enabled, default: false + t.string :smtp_address, default: '' + t.integer :smtp_port, default: 0 + t.string :smtp_email, default: '' + t.string :smtp_password, default: '' + t.string :smtp_domain, default: '' + t.boolean :smtp_enable_starttls_auto, default: true + t.string :smtp_authentication, default: 'login' + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 1e3b87636..66550552f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -181,6 +181,21 @@ ActiveRecord::Schema.define(version: 2021_11_18_100301) do t.string "forward_to_email", null: false t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false + t.boolean "imap_enabled", default: false + t.string "imap_address", default: "" + t.integer "imap_port", default: 0 + t.string "imap_email", default: "" + t.string "imap_password", default: "" + t.boolean "imap_enable_ssl", default: true + t.datetime "imap_inbox_synced_at" + t.boolean "smtp_enabled", default: false + t.string "smtp_address", default: "" + t.integer "smtp_port", default: 0 + t.string "smtp_email", default: "" + t.string "smtp_password", default: "" + t.string "smtp_domain", default: "" + t.boolean "smtp_enable_starttls_auto", default: true + t.string "smtp_authentication", default: "login" t.index ["email"], name: "index_channel_email_on_email", unique: true t.index ["forward_to_email"], name: "index_channel_email_on_forward_to_email", unique: true end diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index c1b8b7416..9fdceac6f 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -376,6 +376,57 @@ RSpec.describe 'Inboxes API', type: :request do expect(email_channel.reload.email).to eq('emailtest@email.test') end + it 'updates email inbox with imap when administrator' do + email_channel = create(:channel_email, account: account) + email_inbox = create(:inbox, channel: email_channel, account: account) + + imap_connection = double + allow(Mail).to receive(:connection).and_return(imap_connection) + + patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}", + headers: admin.create_new_auth_token, + params: { + channel: { + imap_enabled: true, + imap_address: 'imap.gmail.com', + imap_port: 993, + imap_email: 'imaptest@gmail.com' + } + }, + as: :json + + expect(response).to have_http_status(:success) + expect(email_channel.reload.imap_enabled).to be true + expect(email_channel.reload.imap_address).to eq('imap.gmail.com') + expect(email_channel.reload.imap_port).to eq(993) + end + + it 'updates email inbox with smtp when administrator' do + email_channel = create(:channel_email, account: account) + email_inbox = create(:inbox, channel: email_channel, account: account) + + smtp_connection = double + allow(smtp_connection).to receive(:finish).and_return(true) + allow(Net::SMTP).to receive(:start).and_return(smtp_connection) + + patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}", + headers: admin.create_new_auth_token, + params: { + channel: { + smtp_enabled: true, + smtp_address: 'smtp.gmail.com', + smtp_port: 587, + smtp_email: 'smtptest@gmail.com' + } + }, + as: :json + + expect(response).to have_http_status(:success) + expect(email_channel.reload.smtp_enabled).to be true + expect(email_channel.reload.smtp_address).to eq('smtp.gmail.com') + expect(email_channel.reload.smtp_port).to eq(587) + end + it 'updates avatar when administrator' do # no avatar before upload expect(inbox.avatar.attached?).to eq(false) diff --git a/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb b/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb new file mode 100644 index 000000000..210602ac9 --- /dev/null +++ b/spec/jobs/inboxes/fetch_imap_email_inboxes_job_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe Inboxes::FetchImapEmailInboxesJob, type: :job do + let(:account) { create(:account) } + let(:imap_email_channel) do + create(:channel_email, imap_enabled: true, imap_address: 'imap.gmail.com', imap_port: 993, imap_email: 'imap@gmail.com', + imap_password: 'password', account: account) + end + let(:email_inbox) { create(:inbox, channel: imap_email_channel, account: account) } + + it 'enqueues the job' do + expect { described_class.perform_later }.to have_enqueued_job(described_class) + .on_queue('low') + end + + context 'when called' do + it 'fetch all the email channels' do + imap_email_inboxes = double + allow(imap_email_inboxes).to receive(:all).and_return([email_inbox]) + allow(Inbox).to receive(:where).and_return(imap_email_inboxes) + + expect(Inboxes::FetchImapEmailsJob).to receive(:perform_later).with(imap_email_channel).once + + described_class.perform_now + end + end +end diff --git a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb new file mode 100644 index 000000000..6c1beecaf --- /dev/null +++ b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +RSpec.describe Inboxes::FetchImapEmailsJob, type: :job do + let(:account) { create(:account) } + let(:imap_email_channel) do + create(:channel_email, imap_enabled: true, imap_address: 'imap.gmail.com', imap_port: 993, imap_email: 'imap@gmail.com', + imap_password: 'password', imap_inbox_synced_at: Time.now.utc - 10, account: account) + end + let(:email_inbox) { create(:inbox, channel: imap_email_channel, account: account) } + + it 'enqueues the job' do + expect { described_class.perform_later }.to have_enqueued_job(described_class) + .on_queue('low') + end + + context 'when imap fetch latest 10 emails' do + it 'check for the new emails' do + mail_date = Time.now.utc + mail = Mail.new do + to 'test@outlook.com' + from 'test@gmail.com' + subject :test.to_s + body 'hello' + date mail_date + end + + allow(Mail).to receive(:find).and_return([mail]) + imap_mailbox = double + allow(Imap::ImapMailbox).to receive(:new).and_return(imap_mailbox) + expect(imap_mailbox).to receive(:process).with(mail, imap_email_channel).once + + described_class.perform_now(imap_email_channel) + + expect(imap_email_channel.reload.imap_inbox_synced_at).to be > mail_date + end + end +end diff --git a/spec/mailboxes/imap/imap_mailbox_spec.rb b/spec/mailboxes/imap/imap_mailbox_spec.rb new file mode 100644 index 000000000..0f44e2a40 --- /dev/null +++ b/spec/mailboxes/imap/imap_mailbox_spec.rb @@ -0,0 +1,93 @@ +require 'rails_helper' + +RSpec.describe Imap::ImapMailbox, type: :mailbox do + include ActionMailbox::TestHelper + + describe 'add mail as a new conversation in the email inbox' do + let(:account) { create(:account) } + let(:agent) { create(:user, email: 'agent@example.com', account: account) } + let(:channel) do + create(:channel_email, imap_enabled: true, imap_address: 'imap.gmail.com', + imap_port: 993, imap_email: 'imap@gmail.com', imap_password: 'password', + account: account) + end + let(:inbox) { create(:inbox, channel: channel, account: account) } + let!(:contact) { create(:contact, email: 'email@gmail.com', phone_number: '+919584546666', account: account, identifier: '123') } + let(:conversation) { Conversation.where(inbox_id: channel.inbox).last } + let(:class_instance) { described_class.new } + + before do + create(:contact_inbox, contact_id: contact.id, inbox_id: channel.inbox.id) + end + + context 'when a new email from non existing contact' do + let(:inbound_mail) { create_inbound_email_from_mail(from: 'testemail@gmail.com', to: 'imap@gmail.com', subject: 'Hello!') } + + it 'creates the contact and conversation with message' do + class_instance.process(inbound_mail.mail, channel) + expect(conversation.contact.email).to eq(inbound_mail.mail.from.first) + expect(conversation.additional_attributes['source']).to eq('email') + expect(conversation.messages.empty?).to be false + end + end + + context 'when a new email from existing contact' do + let(:inbound_mail) { create_inbound_email_from_mail(from: 'email@gmail.com', to: 'imap@gmail.com', subject: 'Hello!') } + + it 'creates a new conversation with message' do + class_instance.process(inbound_mail.mail, channel) + expect(conversation.contact.email).to eq(contact.email) + expect(conversation.additional_attributes['source']).to eq('email') + expect(conversation.messages.empty?).to be false + 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 + create_inbound_email_from_mail(from: 'email@gmail.com', to: 'imap@gmail.com', subject: 'Hello!', in_reply_to: 'test-in-reply-to') + end + + it 'appends new email to the existing conversation' do + create( + :message, + content: 'Incoming Message', + message_type: 'incoming', + inbox: inbox, + account: account, + conversation: prev_conversation + ) + create( + :message, + content: 'Outgoing Message', + message_type: 'outgoing', + inbox: inbox, + source_id: 'test-in-reply-to', + account: account, + conversation: prev_conversation + ) + + expect(prev_conversation.messages.size).to eq(2) + + class_instance.process(reply_mail.mail, channel) + + expect(prev_conversation.messages.size).to eq(3) + expect(prev_conversation.messages.last.content_attributes['email']['from']).to eq(reply_mail.mail.from) + expect(prev_conversation.messages.last.content_attributes['email']['to']).to eq(reply_mail.mail.to) + expect(prev_conversation.messages.last.content_attributes['email']['subject']).to eq(reply_mail.mail.subject) + expect(prev_conversation.messages.last.content_attributes['email']['in_reply_to']).to eq(reply_mail.mail.in_reply_to) + end + end + + context 'when a reply for non existing email conversation' do + let(:reply_mail) do + create_inbound_email_from_mail(from: 'email@gmail.com', to: 'imap@gmail.com', subject: 'Hello!', in_reply_to: 'test-in-reply-to') + end + + it 'creates new email conversation with incoming in-reply-to' do + class_instance.process(reply_mail.mail, channel) + expect(conversation.additional_attributes['in_reply_to']).to eq(reply_mail.mail.in_reply_to) + end + end + end +end diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index eb9799ddf..c1d6d18dd 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -154,6 +154,32 @@ RSpec.describe ConversationReplyMailer, type: :mailer do end end + context 'when smtp enabled for email channel' do + let(:smtp_email_channel) do + create(:channel_email, smtp_enabled: true, smtp_address: 'smtp.gmail.com', smtp_port: 587, smtp_email: 'smtp@gmail.com', + smtp_password: 'password', smtp_domain: 'smtp.gmail.com', account: account) + end + let(:conversation) { create(:conversation, assignee: agent, inbox: smtp_email_channel.inbox, account: account).reload } + let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') } + + it 'use smtp mail server' do + mail = described_class.email_reply(message) + expect(mail.delivery_method.settings.empty?).to be false + expect(mail.delivery_method.settings[:address]).to eq 'smtp.gmail.com' + expect(mail.delivery_method.settings[:port]).to eq 587 + end + end + + context 'when smtp disabled for email channel', :test 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') } + + it 'use default mail server' do + mail = described_class.email_reply(message) + expect(mail.delivery_method.settings).to be_empty + end + end + context 'when custom domain and email is not enabled' do let(:inbox) { create(:inbox, account: account) } let(:inbox_member) { create(:inbox_member, user: agent, inbox: inbox) }