feat: Support cc and bcc in email replies (#3098)

Co-authored-by: Tejaswini <tejaswini@chatwoot.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Nithin David Thomas 2021-10-11 13:00:48 +05:30 committed by GitHub
parent 0e0632be22
commit 68e697c379
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 135 additions and 25 deletions

View file

@ -16,6 +16,7 @@ class Messages::MessageBuilder
def perform def perform
@message = @conversation.messages.build(message_params) @message = @conversation.messages.build(message_params)
process_attachments process_attachments
process_emails
@message.save! @message.save!
@message @message
end end
@ -34,6 +35,16 @@ class Messages::MessageBuilder
end end
end end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails]
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails]
@message.content_attributes[:cc_emails] = cc_emails
@message.content_attributes[:bcc_emails] = bcc_emails
end
def message_type def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes' raise StandardError, 'Incoming messages are only allowed in Api inboxes'

View file

@ -8,6 +8,8 @@ export const buildCreatePayload = ({
contentAttributes, contentAttributes,
echoId, echoId,
file, file,
ccEmails,
bccEmails,
}) => { }) => {
let payload; let payload;
if (file) { if (file) {
@ -18,12 +20,16 @@ export const buildCreatePayload = ({
} }
payload.append('private', isPrivate); payload.append('private', isPrivate);
payload.append('echo_id', echoId); payload.append('echo_id', echoId);
payload.append('cc_emails', ccEmails);
payload.append('bcc_emails', bccEmails);
} else { } else {
payload = { payload = {
content: message, content: message,
private: isPrivate, private: isPrivate,
echo_id: echoId, echo_id: echoId,
content_attributes: contentAttributes, content_attributes: contentAttributes,
cc_emails: ccEmails,
bcc_emails: bccEmails,
}; };
} }
return payload; return payload;
@ -41,6 +47,8 @@ class MessageApi extends ApiClient {
contentAttributes, contentAttributes,
echo_id: echoId, echo_id: echoId,
file, file,
ccEmails,
bccEmails,
}) { }) {
return axios({ return axios({
method: 'post', method: 'post',
@ -51,6 +59,8 @@ class MessageApi extends ApiClient {
contentAttributes, contentAttributes,
echoId, echoId,
file, file,
ccEmails,
bccEmails,
}), }),
}); });
} }

View file

@ -3,8 +3,9 @@
<div :class="wrapClass"> <div :class="wrapClass">
<div v-tooltip.top-start="sentByMessage" :class="bubbleClass"> <div v-tooltip.top-start="sentByMessage" :class="bubbleClass">
<bubble-mail-head <bubble-mail-head
v-if="isEmailContentType"
:email-attributes="contentAttributes.email" :email-attributes="contentAttributes.email"
:cc="emailHeadAttributes.cc"
:bcc="emailHeadAttributes.bcc"
:is-incoming="isIncoming" :is-incoming="isIncoming"
/> />
<bubble-text <bubble-text
@ -222,6 +223,13 @@ export default {
isIncoming() { isIncoming() {
return this.data.message_type === MESSAGE_TYPE.INCOMING; return this.data.message_type === MESSAGE_TYPE.INCOMING;
}, },
emailHeadAttributes() {
return {
email: this.contentAttributes.email,
cc: this.contentAttributes.cc_emails,
bcc: this.contentAttributes.bcc_emails
}
},
hasAttachments() { hasAttachments() {
return !!(this.data.attachments && this.data.attachments.length > 0); return !!(this.data.attachments && this.data.attachments.length > 0);
}, },

View file

@ -20,6 +20,11 @@
v-on-clickaway="hideEmojiPicker" v-on-clickaway="hideEmojiPicker"
:on-click="emojiOnClick" :on-click="emojiOnClick"
/> />
<reply-email-head
v-if="showReplyHead"
@set-emails="setCcEmails"
:clear-mails="clearMails"
/>
<resizable-text-area <resizable-text-area
v-if="!showRichContentEditor" v-if="!showRichContentEditor"
ref="messageInput" ref="messageInput"
@ -82,6 +87,7 @@ import CannedResponse from './CannedResponse';
import ResizableTextArea from 'shared/components/ResizableTextArea'; import ResizableTextArea from 'shared/components/ResizableTextArea';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview'; import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel'; import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel';
import ReplyEmailHead from './ReplyEmailHead';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel'; import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants'; import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
@ -104,6 +110,7 @@ export default {
ResizableTextArea, ResizableTextArea,
AttachmentPreview, AttachmentPreview,
ReplyTopPanel, ReplyTopPanel,
ReplyEmailHead,
ReplyBottomPanel, ReplyBottomPanel,
WootMessageEditor, WootMessageEditor,
}, },
@ -134,6 +141,7 @@ export default {
mentionSearchKey: '', mentionSearchKey: '',
hasUserMention: false, hasUserMention: false,
hasSlashCommand: false, hasSlashCommand: false,
clearMails: false,
}; };
}, },
computed: { computed: {
@ -270,6 +278,9 @@ export default {
} }
return !this.message.trim().replace(/\n/g, '').length; return !this.message.trim().replace(/\n/g, '').length;
}, },
showReplyHead(){
return this.isAnEmailChannel;
},
}, },
watch: { watch: {
currentChat(conversation) { currentChat(conversation) {
@ -354,6 +365,7 @@ export default {
this.showAlert(errorMessage); this.showAlert(errorMessage);
} }
this.hideEmojiPicker(); this.hideEmojiPicker();
this.clearMails = false;
} }
}, },
replaceText(message) { replaceText(message) {
@ -376,6 +388,7 @@ export default {
clearMessage() { clearMessage() {
this.message = ''; this.message = '';
this.attachedFiles = []; this.attachedFiles = [];
this.clearMails = true;
}, },
toggleEmojiPicker() { toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker; this.showEmojiPicker = !this.showEmojiPicker;
@ -452,11 +465,23 @@ export default {
messagePayload.file = attachment.resource.file; messagePayload.file = attachment.resource.file;
} }
if(this.ccEmails) {
messagePayload.ccEmails = this.ccEmails;
}
if(this.bccEmails) {
messagePayload.bccEmails = this.bccEmails;
}
return messagePayload; return messagePayload;
}, },
setFormatMode(value) { setFormatMode(value) {
this.updateUISettings({ display_rich_content_editor: value }); this.updateUISettings({ display_rich_content_editor: value });
}, },
setCcEmails(value) {
this.bccEmails = value.bccEmails;
this.ccEmails = value.ccEmails
}
}, },
}; };
</script> </script>

View file

@ -1,17 +1,17 @@
<template> <template>
<div> <div>
<div class="input-group-wrap"> <div class="input-group-wrap">
<div class="input-group small" :class="{ error: $v.ccEmails.$error }"> <div class="input-group small" :class="{ error: $v.ccEmailsVal.$error }">
<label class="input-group-label"> <label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.LABEL') }} {{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.LABEL') }}
</label> </label>
<div class="input-group-field"> <div class="input-group-field">
<woot-input <woot-input
v-model.trim="ccEmails" v-model.trim="$v.ccEmailsVal.$model"
type="email" type="email"
:class="{ error: $v.ccEmails.$error }" :class="{ error: $v.ccEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')" :placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="$v.ccEmails.$touch" @blur="onBlur"
/> />
</div> </div>
<woot-button <woot-button
@ -23,28 +23,28 @@
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.ADD_BCC') }} {{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.ADD_BCC') }}
</woot-button> </woot-button>
</div> </div>
<span v-if="$v.ccEmails.$error" class="message"> <span v-if="$v.ccEmailsVal.$error" class="message">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.ERROR') }} {{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.ERROR') }}
</span> </span>
</div> </div>
<div v-if="showBcc" class="input-group-wrap"> <div v-if="showBcc" class="input-group-wrap">
<div class="input-group small" :class="{ error: $v.bccEmails.$error }"> <div class="input-group small" :class="{ error: $v.bccEmailsVal.$error }">
<label class="input-group-label"> <label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.LABEL') }} {{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.LABEL') }}
</label> </label>
<div class="input-group-field"> <div class="input-group-field">
<woot-input <woot-input
v-model.trim="bccEmails" v-model.trim="$v.bccEmailsVal.$model"
type="email" type="email"
:class="{ error: $v.bccEmails.$error }" :class="{ error: $v.bccEmailsVal.$error }"
:placeholder=" :placeholder="
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER') $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')
" "
@blur="$v.bccEmails.$touch" @blur="onBlur"
/> />
</div> </div>
</div> </div>
<span v-if="$v.bccEmails.$error" class="message"> <span v-if="$v.bccEmailsVal.$error" class="message">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.ERROR') }} {{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.ERROR') }}
</span> </span>
</div> </div>
@ -55,27 +55,25 @@
import { validEmailsByComma } from './helpers/emailHeadHelper'; import { validEmailsByComma } from './helpers/emailHeadHelper';
export default { export default {
props: { props: {
ccEmails: { clearMails: {
type: String, type: Boolean,
default: '', default: false,
},
bccEmails: {
type: String,
default: '',
}, },
}, },
data() { data() {
return { return {
showBcc: false, showBcc: false,
ccEmailsVal: '',
bccEmailsVal: '',
}; };
}, },
validations: { validations: {
ccEmails: { ccEmailsVal: {
hasValidEmails(value) { hasValidEmails(value) {
return validEmailsByComma(value); return validEmailsByComma(value);
}, },
}, },
bccEmails: { bccEmailsVal: {
hasValidEmails(value) { hasValidEmails(value) {
return validEmailsByComma(value); return validEmailsByComma(value);
}, },
@ -85,7 +83,20 @@ export default {
handleAddBcc() { handleAddBcc() {
this.showBcc = true; this.showBcc = true;
}, },
onBlur() {
this.$v.$touch();
this.$emit("set-emails", { bccEmails: this.bccEmailsVal, ccEmails: this.ccEmailsVal });
},
}, },
watch: {
clearMails: function(value){
if(value) {
this.ccEmailsVal = '';
this.bccEmailsVal = '';
this.clearMails = false;
}
}
}
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -36,6 +36,14 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
cc: {
type: Array,
default: [],
},
bcc: {
type: Array,
default: [],
}
}, },
computed: { computed: {
toMails() { toMails() {
@ -43,11 +51,11 @@ export default {
return to.join(', '); return to.join(', ');
}, },
ccMails() { ccMails() {
const cc = this.emailAttributes.cc || []; const cc = this.emailAttributes.cc || this.cc || [];
return cc.join(', '); return cc.join(', ');
}, },
bccMails() { bccMails() {
const bcc = this.emailAttributes.bcc || []; const bcc = this.emailAttributes.bcc || this.bcc || [];
return bcc.join(', '); return bcc.join(', ');
}, },
subject() { subject() {

View file

@ -19,7 +19,9 @@ class ConversationReplyMailer < ApplicationMailer
reply_to: reply_email, reply_to: reply_email,
subject: mail_subject, subject: mail_subject,
message_id: custom_message_id, message_id: custom_message_id,
in_reply_to: in_reply_to_email in_reply_to: in_reply_to_email,
cc: cc_bcc_emails[0],
bcc: cc_bcc_emails[1]
}) })
end end
@ -39,7 +41,9 @@ class ConversationReplyMailer < ApplicationMailer
reply_to: reply_email, reply_to: reply_email,
subject: mail_subject, subject: mail_subject,
message_id: custom_message_id, message_id: custom_message_id,
in_reply_to: in_reply_to_email in_reply_to: in_reply_to_email,
cc: cc_bcc_emails[0],
bcc: cc_bcc_emails[1]
}) })
end end
@ -142,6 +146,15 @@ class ConversationReplyMailer < ApplicationMailer
nil nil
end end
def cc_bcc_emails
content_attributes = @conversation.messages.outgoing.last&.content_attributes
return [] unless content_attributes
return [] unless content_attributes[:cc_emails] || content_attributes[:bcc_emails]
[content_attributes[:cc_emails], content_attributes[:bcc_emails]]
end
def inbound_email_enabled? def inbound_email_enabled?
@inbound_email_enabled ||= @account.feature_enabled?('inbound_emails') && @account.inbound_email_domain @inbound_email_enabled ||= @account.feature_enabled?('inbound_emails') && @account.inbound_email_domain
.present? && @account.support_email.present? .present? && @account.support_email.present?

View file

@ -15,9 +15,28 @@ RSpec.describe ConversationReplyMailer, type: :mailer do
context 'with summary' do context 'with summary' do
let(:conversation) { create(:conversation, account: account, assignee: agent) } let(:conversation) { create(:conversation, account: account, assignee: agent) }
let(:message) { create(:message, account: account, conversation: conversation) } let(:message) do
create(:message,
account: account,
conversation: conversation,
content_attributes: {
cc_emails: 'agent_cc1@example.com',
bcc_emails: 'agent_bcc1@example.com'
})
end
let(:cc_message) do
create(:message,
account: account,
message_type: :outgoing,
conversation: conversation,
content_attributes: {
cc_emails: 'agent_cc1@example.com',
bcc_emails: 'agent_bcc1@example.com'
})
end
let(:private_message) { create(:message, account: account, content: 'This is a private message', conversation: conversation) } let(:private_message) { create(:message, account: account, content: 'This is a private message', conversation: conversation) }
let(:mail) { described_class.reply_with_summary(message.conversation, Time.zone.now).deliver_now } let(:mail) { described_class.reply_with_summary(message.conversation, Time.zone.now).deliver_now }
let(:cc_mail) { described_class.reply_with_summary(cc_message.conversation, Time.zone.now).deliver_now }
it 'renders the subject' do it 'renders the subject' do
expect(mail.subject).to eq("[##{message.conversation.display_id}] New messages on this conversation") expect(mail.subject).to eq("[##{message.conversation.display_id}] New messages on this conversation")
@ -37,6 +56,11 @@ RSpec.describe ConversationReplyMailer, type: :mailer do
conversation.update(contact_last_seen_at: Time.zone.now) conversation.update(contact_last_seen_at: Time.zone.now)
expect(mail).to eq nil expect(mail).to eq nil
end end
it 'will send email to cc and bcc email addresses' do
expect(cc_mail.cc.first).to eq(cc_message.content_attributes[:cc_emails])
expect(cc_mail.bcc.first).to eq(cc_message.content_attributes[:bcc_emails])
end
end end
context 'without assignee' do context 'without assignee' do