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
This commit is contained in:
Aswin Dev P.S 2021-11-18 22:22:27 -08:00 committed by GitHub
parent 8384d0b38e
commit 24e6a92297
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1040 additions and 57 deletions

View file

@ -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

View file

@ -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

View file

@ -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"
}
}
}
}

View file

@ -0,0 +1,163 @@
<template>
<div class="settings--content">
<settings-section
:title="$t('INBOX_MGMT.IMAP.TITLE')"
:sub-title="$t('INBOX_MGMT.IMAP.SUBTITLE')"
>
<form @submit.prevent="updateInbox">
<label for="toggle-imap-enable">
<input
v-model="isIMAPEnabled"
type="checkbox"
name="toggle-imap-enable"
/>
{{ $t('INBOX_MGMT.IMAP.TOGGLE_AVAILABILITY') }}
</label>
<p>{{ $t('INBOX_MGMT.IMAP.TOGGLE_HELP') }}</p>
<div v-if="isIMAPEnabled" class="imap-details-wrap">
<woot-input
v-model.trim="address"
:class="{ error: $v.address.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.IMAP.ADDRESS.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.ADDRESS.PLACE_HOLDER')"
@blur="$v.address.$touch"
/>
<woot-input
v-model="port"
type="number"
:class="{ error: $v.port.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.IMAP.PORT.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.PORT.PLACE_HOLDER')"
@blur="$v.port.$touch"
/>
<woot-input
v-model="email"
:class="{ error: $v.email.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.IMAP.EMAIL.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.EMAIL.PLACE_HOLDER')"
@blur="$v.email.$touch"
/>
<woot-input
v-model="password"
:class="{ error: $v.password.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.IMAP.PASSWORD.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.PASSWORD.PLACE_HOLDER')"
type="password"
@blur="$v.password.$touch"
/>
<label for="toggle-enable-ssl">
<input
v-model="isSSLEnabled"
type="checkbox"
name="toggle-enable-ssl"
/>
{{ $t('INBOX_MGMT.IMAP.ENABLE_SSL') }}
</label>
</div>
<woot-submit-button
:button-text="$t('INBOX_MGMT.IMAP.UPDATE')"
:loading="uiFlags.isUpdatingInbox"
:disabled="($v.$invalid && isIMAPEnabled) || uiFlags.isUpdatingIMAP"
/>
</form>
</settings-section>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import SettingsSection from 'dashboard/components/SettingsSection';
import { required, minLength, email } from 'vuelidate/lib/validators';
export default {
components: {
SettingsSection,
},
mixins: [alertMixin],
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
isIMAPEnabled: false,
address: '',
port: '',
email: '',
password: '',
isSSLEnabled: true,
};
},
validations: {
address: { required },
port: { required, minLength: minLength(2) },
email: { required, email },
password: { required },
},
computed: {
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
},
watch: {
inbox() {
this.setDefaults();
},
},
mounted() {
this.setDefaults();
},
methods: {
setDefaults() {
const {
imap_enabled,
imap_address,
imap_port,
imap_email,
imap_password,
imap_enable_ssl,
} = this.inbox;
this.isIMAPEnabled = imap_enabled;
this.address = imap_address;
this.port = imap_port;
this.email = imap_email;
this.password = imap_password;
this.isSSLEnabled = imap_enable_ssl;
},
async updateInbox() {
try {
this.loading = true;
const payload = {
id: this.inbox.id,
formData: false,
channel: {
imap_enabled: this.isIMAPEnabled,
imap_address: this.address,
imap_port: this.port,
imap_email: this.email,
imap_password: this.password,
imap_enable_ssl: this.isSSLEnabled,
imap_inbox_synced_at: this.isIMAPEnabled
? new Date().toISOString()
: undefined,
},
};
await this.$store.dispatch('inboxes/updateInboxIMAP', payload);
this.showAlert(this.$t('INBOX_MGMT.IMAP.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.IMAP.EDIT.ERROR_MESSAGE'));
}
},
},
};
</script>
<style lang="scss" scoped>
.imap-details-wrap {
margin-bottom: var(--space-medium);
}
</style>

View file

@ -353,6 +353,8 @@
<woot-code :script="inbox.forward_to_email"></woot-code>
</settings-section>
</div>
<imap-settings :inbox="inbox" />
<smtp-settings :inbox="inbox" />
</div>
</div>
<div v-if="selectedTabKey === 'preChatForm'">
@ -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() {

View file

@ -0,0 +1,163 @@
<template>
<div class="settings--content">
<settings-section
:title="$t('INBOX_MGMT.SMTP.TITLE')"
:sub-title="$t('INBOX_MGMT.SMTP.SUBTITLE')"
>
<form @submit.prevent="updateInbox">
<label for="toggle-enable-smtp">
<input
v-model="isSMTPEnabled"
type="checkbox"
name="toggle-enable-smtp"
/>
{{ $t('INBOX_MGMT.SMTP.TOGGLE_AVAILABILITY') }}
</label>
<p>{{ $t('INBOX_MGMT.SMTP.TOGGLE_HELP') }}</p>
<div v-if="isSMTPEnabled" class="smtp-details-wrap">
<woot-input
v-model.trim="address"
:class="{ error: $v.address.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.ADDRESS.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.ADDRESS.PLACE_HOLDER')"
@blur="$v.address.$touch"
/>
<woot-input
v-model="port"
type="number"
:class="{ error: $v.port.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.PORT.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.PORT.PLACE_HOLDER')"
@blur="$v.port.$touch"
/>
<woot-input
v-model="email"
:class="{ error: $v.email.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.EMAIL.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.EMAIL.PLACE_HOLDER')"
@blur="$v.email.$touch"
/>
<woot-input
v-model="password"
:class="{ error: $v.password.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.PASSWORD.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.PASSWORD.PLACE_HOLDER')"
type="password"
@blur="$v.password.$touch"
/>
<woot-input
v-model.trim="domain"
:class="{ error: $v.domain.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.DOMAIN.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.DOMAIN.PLACE_HOLDER')"
@blur="$v.domain.$touch"
/>
</div>
<woot-submit-button
:button-text="$t('INBOX_MGMT.SMTP.UPDATE')"
:loading="uiFlags.isUpdatingInbox"
:disabled="($v.$invalid && isSMTPEnabled) || uiFlags.isUpdatingSMTP"
/>
</form>
</settings-section>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import SettingsSection from 'dashboard/components/SettingsSection';
import { required, minLength, email } from 'vuelidate/lib/validators';
export default {
components: {
SettingsSection,
},
mixins: [alertMixin],
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
isSMTPEnabled: false,
address: '',
port: '',
email: '',
password: '',
domain: '',
};
},
validations: {
address: { required },
port: {
required,
minLength: minLength(2),
},
email: { required, email },
password: { required },
domain: { required },
},
computed: {
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
},
watch: {
inbox() {
this.setDefaults();
},
},
mounted() {
this.setDefaults();
},
methods: {
setDefaults() {
const {
smtp_enabled,
smtp_address,
smtp_port,
smtp_email,
smtp_password,
smtp_domain,
} = this.inbox;
this.isSMTPEnabled = smtp_enabled;
this.address = smtp_address;
this.port = smtp_port;
this.email = smtp_email;
this.password = smtp_password;
this.domain = smtp_domain;
},
async updateInbox() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
smtp_enabled: this.isSMTPEnabled,
smtp_address: this.address,
smtp_port: this.port,
smtp_email: this.email,
smtp_password: this.password,
smtp_domain: this.domain,
},
};
await this.$store.dispatch('inboxes/updateInboxSMTP', payload);
this.showAlert(this.$t('INBOX_MGMT.SMTP.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE'));
}
},
},
};
</script>
<style lang="scss" scoped>
.smtp-details-wrap {
margin-bottom: var(--space-medium);
}
</style>

View file

@ -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 {

View file

@ -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] });

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 <accounts@chatwoot.com>')).address

View file

@ -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

View file

@ -1,4 +1,5 @@
class ConversationReplyMailer < ApplicationMailer
include ConversationReplyMailerHelper
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
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?

View file

@ -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

View file

@ -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

View file

@ -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?

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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) }